feat(mazenet): persist canvas layout per topology to localStorage

Dragging a LAN or decky, or resizing a NetBox, updates React state
but previously vanished on reload because the grid-layout adapter
rewrote everything from the graph. Add a per-topology localStorage
snapshot (key: mazenet.layout.<topologyId>) that captures net
x/y/w/h and decky x/y; useLayoutPersistor writes it debounced, and
getTopology merges it over adaptTopology's grid so entities without
a stored entry still fall back to a clean auto-layout. Deleting a
topology calls clearLayout to drop its snapshot.
This commit is contained in:
2026-04-20 23:52:00 -04:00
parent c4be1c721d
commit 167582b887
4 changed files with 121 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { useMazeApi } from './useMazeApi';
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
import { useLayoutPersistor } from './useMazeLayoutStore';
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
/* Short unique suffix for default names — avoids the DB uniqueness
@@ -43,6 +44,8 @@ const MazeNET: React.FC = () => {
const [inspectorOpen, setInspectorOpen] = useState(true);
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
useLayoutPersistor(topologyId || null, nets, nodes);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [actionErr, setActionErr] = useState<string | null>(null);
const [deploying, setDeploying] = useState(false);

View File

@@ -3,6 +3,7 @@ import api from '../../utils/api';
import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data';
import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { applyLayout, loadLayout } from './useMazeLayoutStore';
export interface LANRow {
id: string;
@@ -214,7 +215,10 @@ export function useMazeApi(): MazeApi {
const getTopology = useCallback(async (id: string) => {
const { data } = await api.get<TopologyDetail>(`/topologies/${id}`);
return adaptTopology(data);
const hydrated = adaptTopology(data);
const layout = loadLayout(id);
const { nets, nodes } = applyLayout(hydrated.nets, hydrated.nodes, layout);
return { ...hydrated, nets, nodes };
}, []);
const getServices = useCallback(async () => {

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef } from 'react';
import type { Net, MazeNode } from './types';
/** Per-topology canvas layout persisted to localStorage. Keyed by
* topology id so two topologies don't share positions. Stored keys
* for missing LAN/decky ids are pruned on save (self-heal). */
interface NetLayout { x: number; y: number; w: number; h: number }
interface NodeLayout { x: number; y: number }
export interface LayoutSnapshot {
nets: Record<string, NetLayout>;
nodes: Record<string, NodeLayout>;
}
const EMPTY: LayoutSnapshot = { nets: {}, nodes: {} };
const SAVE_DEBOUNCE_MS = 300;
function storageKey(topologyId: string): string {
return `mazenet.layout.${topologyId}`;
}
export function loadLayout(topologyId: string | null): LayoutSnapshot {
if (!topologyId) return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(topologyId));
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LayoutSnapshot>;
return {
nets: parsed.nets ?? {},
nodes: parsed.nodes ?? {},
};
} catch {
return EMPTY;
}
}
function saveLayout(topologyId: string, snap: LayoutSnapshot): void {
try {
window.localStorage.setItem(storageKey(topologyId), JSON.stringify(snap));
} catch {
/* quota exhausted or private mode — layout reverts to grid. */
}
}
/** Apply stored positions on top of grid-laid-out entities. Entities
* without a stored entry keep their grid position. */
export function applyLayout(
nets: Net[],
nodes: MazeNode[],
layout: LayoutSnapshot,
): { nets: Net[]; nodes: MazeNode[] } {
const adjustedNets = nets.map((n) => {
const saved = layout.nets[n.id];
return saved ? { ...n, x: saved.x, y: saved.y, w: saved.w, h: saved.h } : n;
});
const adjustedNodes = nodes.map((n) => {
const saved = layout.nodes[n.id];
return saved ? { ...n, x: saved.x, y: saved.y } : n;
});
return { nets: adjustedNets, nodes: adjustedNodes };
}
/** Debounced writer — every nets/nodes change is captured and flushed
* to localStorage after a short idle window. Also prunes entries for
* LANs / deckies that no longer exist in the current topology. */
export function useLayoutPersistor(
topologyId: string | null,
nets: Net[],
nodes: MazeNode[],
): void {
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (!topologyId) return;
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
const snap: LayoutSnapshot = { nets: {}, nodes: {} };
for (const n of nets) {
if (n.kind === 'internet') continue;
snap.nets[n.id] = { x: n.x, y: n.y, w: n.w, h: n.h };
}
for (const n of nodes) {
snap.nodes[n.id] = { x: n.x, y: n.y };
}
saveLayout(topologyId, snap);
timerRef.current = null;
}, SAVE_DEBOUNCE_MS);
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [topologyId, nets, nodes]);
}
/** Clear the stored layout for a topology — call after delete so stale
* entries don't linger forever. */
export function clearLayout(topologyId: string): void {
try {
window.localStorage.removeItem(storageKey(topologyId));
} catch {
/* ignore */
}
}
/** Hook form for consumers that prefer a stable callback. */
export function useClearLayout(): (topologyId: string) => void {
return useCallback((id: string) => clearLayout(id), []);
}

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw } from 'lucide-react';
import api from '../../utils/api';
import { clearLayout } from '../MazeNET/useMazeLayoutStore';
import './TopologyList.css';
interface TopologySummary {
@@ -90,6 +91,7 @@ const TopologyList: React.FC = () => {
setBusy(id);
try {
await api.delete(`/topologies/${id}`);
clearLayout(id);
await fetchRows();
} catch (e) {
setErr((e as Error)?.message ?? 'delete failed');