fix(mazenet): auto-layout nets + deckies in a deterministic grid

Dropping more than one LAN near the same spot stacked the NetBox
rectangles on top of each other, and multiple deckies in a LAN
landed on identical per-LAN coordinates. Since canvas position
persistence is deferred (localStorage pass), the stored x/y are
not load-bearing — compute layout from the topology graph instead.

adaptTopology now lays LANs out in a 3-col grid with the DMZ first
and stacks deckies 2-wide inside their home LAN. New LAN palette
drops append to the same grid, ignoring the raw drop point.
This commit is contained in:
2026-04-20 23:47:29 -04:00
parent b261e8e5fa
commit c4be1c721d
2 changed files with 54 additions and 21 deletions

View File

@@ -67,9 +67,14 @@ const MazeNET: React.FC = () => {
flashErr(null, 'topology already has a DMZ');
return;
}
const w = 320, h = 240;
const x = Math.round(world.x - w / 2);
const y = Math.round(world.y - h / 2);
// Append to the 3-col grid matching adaptTopology so new drops
// never land on top of existing LANs. The raw drop point is
// ignored — cleaner than trying to resolve collisions after.
const w = 300, h = 240;
const GAP = 40, COLS = 3;
const i = nets.filter((n) => n.kind !== 'internet').length;
const x = GAP + (i % COLS) * (w + GAP);
const y = GAP + Math.floor(i / COLS) * (h + GAP);
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
try {
const subnet = await api.getNextSubnet().catch(() => undefined);

View File

@@ -62,35 +62,63 @@ export interface HydratedTopology {
* placement. Decky-to-decky traffic edges are derived from
* shared-LAN co-membership for visualization only. */
export function adaptTopology(detail: TopologyDetail): HydratedTopology {
const nets: Net[] = detail.lans.map((lan, i) => ({
// Auto-layout: DMZ pinned top-left, subnets flow in a grid to the right.
// We ignore lan.x/lan.y from the backend because canvas position
// persistence is deferred (handled via localStorage in a later pass).
// Computing layout from the graph keeps the canvas readable no matter
// how sloppy the original drop points were.
const NET_W = 300;
const NET_H = 240;
const GAP_X = 40;
const GAP_Y = 40;
const COLS = 3;
const dmzs = detail.lans.filter((l) => l.is_dmz);
const subnets = detail.lans.filter((l) => !l.is_dmz);
const ordered = [...dmzs, ...subnets];
const nets: Net[] = ordered.map((lan, i) => ({
id: lan.id,
label: lan.name.toUpperCase(),
cidr: lan.subnet,
kind: lan.is_dmz ? 'dmz' : 'subnet',
x: lan.x ?? 40 + (i % 3) * 320,
y: lan.y ?? 40 + Math.floor(i / 3) * 280,
w: 300,
h: 240,
x: GAP_X + (i % COLS) * (NET_W + GAP_X),
y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y),
w: NET_W,
h: NET_H,
}));
// Home LAN = first edge; a multi-homed gateway is drawn inside its
// home LAN, membership in others is expressed via the edge list.
const firstLanFor = new Map<string, string>();
for (const e of detail.edges) {
if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id);
}
const nodes: MazeNode[] = detail.deckies.map((d, i): DeckyNode => ({
kind: 'decky',
id: d.uuid,
netId: firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''),
name: d.name,
archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server',
services: d.services,
status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle',
x: d.x ?? 20 + (i % 2) * 160,
y: d.y ?? 60 + Math.floor(i / 2) * 90,
ip: d.ip ?? undefined,
decky_config: d.decky_config ?? undefined,
}));
// Layout deckies in a 2-column grid inside their home LAN so two
// members never overlap regardless of backend x/y. Same reasoning as
// the LAN grid above.
const NODE_COL_W = 140;
const NODE_ROW_H = 82;
const NODE_X0 = 12;
const NODE_Y0 = 40;
const perNetIndex = new Map<string, number>();
const nodes: MazeNode[] = detail.deckies.map((d): DeckyNode => {
const homeNetId = firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? '');
const idx = perNetIndex.get(homeNetId) ?? 0;
perNetIndex.set(homeNetId, idx + 1);
return {
kind: 'decky',
id: d.uuid,
netId: homeNetId,
name: d.name,
archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server',
services: d.services,
status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle',
x: NODE_X0 + (idx % 2) * NODE_COL_W,
y: NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H,
ip: d.ip ?? undefined,
decky_config: d.decky_config ?? undefined,
};
});
const byLan = new Map<string, string[]>();
for (const e of detail.edges) {