Lay a gateway on the boundary it serves, not the one it faces

Found by rendering the pictures and looking at them, which is the only way
a layout fault shows up.

A gateway was placed below its OUTWARD lane, so one serving `home` and
`devices` was drawn straddling `hosting` and an unrelated `cafe`, with its
connection crossing a network it has nothing to do with. Its first attachment
is the segment it faces; the rest are the ones it serves, and it belongs above
the topmost of those. Transit faces every lane and serves none, so it keeps the
old rule.

Also: the live picture kept its attachments sorted alphabetically, which threw
away the outside-first order the placement now depends on. A segment holding
only gateways-in-the-gaps was counted as occupied and drawn full height with
nothing in it. Badges read left to right, in the order the facts are stated.
The gap between lanes is wide enough that a straddling node no longer covers
the lane's own name and ranges.
This commit is contained in:
2026-08-24 23:08:42 +02:00
parent 2243618f01
commit 5b8d01eacf
2 changed files with 42 additions and 15 deletions
+35 -14
View File
@@ -29,7 +29,10 @@
import type { Diagram, DiagramMachine } from "./model.ts"; import type { Diagram, DiagramMachine } from "./model.ts";
const LANE_MIN_HEIGHT = 170; const LANE_MIN_HEIGHT = 170;
const LANE_GAP = 80; const LANE_EMPTY_HEIGHT = 62;
// Wide enough that a gateway placed on a boundary sits BETWEEN the lanes rather than on
// top of one — an earlier value let a router's box cover the lane's own name and ranges.
const LANE_GAP = 110;
const LANE_X = 40; const LANE_X = 40;
const LANE_WIDTH = 980; const LANE_WIDTH = 980;
const NODE_WIDTH = 150; const NODE_WIDTH = 150;
@@ -163,11 +166,19 @@ export function toDrawio(diagram: Diagram): string {
// wrap onto a second row drew that row outside the box it was supposed to be inside. // wrap onto a second row drew that row outside the box it was supposed to be inside.
const occupants = new Map<string, number>(); const occupants = new Map<string, number>();
for (const machine of diagram.machines) { for (const machine of diagram.machines) {
const first = machine.attachments[0]?.segment; // Only a machine that sits INSIDE a lane occupies it. A gateway is counted against the
if (first) occupants.set(first, (occupants.get(first) ?? 0) + 1); // segment it faces but drawn in the gap, so counting it here left a segment holding no
// machines at full height with nothing in it.
if (machine.attachments.length !== 1) continue;
const only = machine.attachments[0]?.segment;
if (only) occupants.set(only, (occupants.get(only) ?? 0) + 1);
} }
const laneHeight = (name: string): number => { const laneHeight = (name: string): number => {
const rows = Math.max(1, Math.ceil((occupants.get(name) ?? 0) / SLOTS_PER_ROW)); const rows = Math.ceil((occupants.get(name) ?? 0) / SLOTS_PER_ROW);
// A segment whose only occupants are the gateways in the gaps beside it holds nothing
// itself, so it collapses to its own name and ranges. Left at full height it padded a
// layered scenario with empty boxes and pushed the interesting rows apart.
if (rows === 0) return LANE_EMPTY_HEIGHT;
return Math.max(LANE_MIN_HEIGHT, 45 + rows * (NODE_HEIGHT + 45)); return Math.max(LANE_MIN_HEIGHT, 45 + rows * (NODE_HEIGHT + 45));
}; };
@@ -233,13 +244,22 @@ export function toDrawio(diagram: Diagram): string {
const row = Math.floor(slot / SLOTS_PER_ROW); const row = Math.floor(slot / SLOTS_PER_ROW);
x = LANE_X + 40 + column * SLOT_WIDTH; x = LANE_X + 40 + column * SLOT_WIDTH;
if (spans) { if (spans) {
// A gateway straddles, so it sits in the gap below the highest lane it joins — // A gateway straddles a boundary, and WHICH boundary matters. Its first attachment
// which is what makes at-a-glance reading of "this is the way in" work. // is the segment it faces outward on; the rest are the ones it serves. Placing it
const top = machine.attachments // below the outward lane put a gateway serving `home` and `devices` above an
.map((a) => a.segment) // unrelated `cafe`, with its connection crossing a network it has nothing to do
.filter((name) => laneY.has(name)) // with. It belongs immediately above the topmost lane it actually serves.
.reduce((best, name) => ((laneY.get(name) ?? 0) < (laneY.get(best) ?? 0) ? name : best)); const drawn = machine.attachments.map((a) => a.segment).filter((name) => laneY.has(name));
y = (laneY.get(top) ?? 80) + (laneH.get(top) ?? LANE_MIN_HEIGHT) + LANE_GAP / 2 - NODE_HEIGHT / 2; const served = drawn.slice(1);
const highest = (names: string[]) =>
names.reduce((best, name) => ((laneY.get(name) ?? 0) < (laneY.get(best) ?? 0) ? name : best));
if (served.length > 0) {
y = (laneY.get(highest(served)) ?? 80) - LANE_GAP / 2 - NODE_HEIGHT / 2;
} else {
// Transit faces every lane and serves none, so it sits below the topmost.
const top = highest(drawn);
y = (laneY.get(top) ?? 80) + (laneH.get(top) ?? LANE_MIN_HEIGHT) + LANE_GAP / 2 - NODE_HEIGHT / 2;
}
} else { } else {
y = (laneY.get(first.segment) ?? 80) + 45 + row * (NODE_HEIGHT + 45); y = (laneY.get(first.segment) ?? 80) + 45 + row * (NODE_HEIGHT + 45);
} }
@@ -266,14 +286,15 @@ export function toDrawio(diagram: Diagram): string {
.join(" · "), .join(" · "),
}); });
// Badges sit along the top edge, right to left, so the first fact stated is nearest the // Badges sit along the top edge and read left to right in the order the facts are
// resource's own corner and the row grows away from the label underneath it. // stated, which is the order the declaration states them in.
const badgeRun = badges.length * BADGE + Math.max(0, badges.length - 1) * 3;
badges.forEach((badge, index) => { badges.forEach((badge, index) => {
cells.push({ cells.push({
id: `${id}-b${index}`, id: `${id}-b${index}`,
value: badge.code, value: badge.code,
style: `${STYLE.badge};fillColor=${badge.fill};strokeColor=${badge.stroke}`, style: `${STYLE.badge};fillColor=${badge.fill};strokeColor=${badge.stroke}`,
x: x + NODE_WIDTH - BADGE - index * (BADGE + 3), x: x + NODE_WIDTH - badgeRun + index * (BADGE + 3),
y: y - BADGE / 2, y: y - BADGE / 2,
w: BADGE, w: BADGE,
h: BADGE, h: BADGE,
+7 -1
View File
@@ -87,7 +87,13 @@ export async function diagramFromLive(instanceId: string): Promise<Diagram> {
const addresses = (mac ? heldByMac.get(mac) : undefined) ?? heldByName.get(device) ?? []; const addresses = (mac ? heldByMac.get(mac) : undefined) ?? heldByName.get(device) ?? [];
attachments.push({ segment, addresses }); attachments.push({ segment, addresses });
} }
attachments.sort((a, b) => a.segment.localeCompare(b.segment)); // Outside first, then the segments behind it — the same order the declared picture uses,
// and what lets the layout place a gateway above the lanes it SERVES rather than below
// the one it faces. Sorting alphabetically threw that away.
const outside = config["user.mesh-lab.outside"];
attachments.sort((a, b) =>
a.segment === outside ? -1 : b.segment === outside ? 1 : a.segment.localeCompare(b.segment),
);
const notes: string[] = []; const notes: string[] = [];
notes.push(item.type === "container" ? "container" : "virtual machine"); notes.push(item.type === "container" ? "container" : "virtual machine");