The lab raises a mesh, draws it, and now places tier 0 inside it #1

Merged
jschoubben merged 11 commits from feat/scenario-lifecycle into main 2026-08-25 23:02:29 +00:00
2 changed files with 42 additions and 15 deletions
Showing only changes of commit 5b8d01eacf - Show all commits
+35 -14
View File
@@ -29,7 +29,10 @@
import type { Diagram, DiagramMachine } from "./model.ts";
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_WIDTH = 980;
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.
const occupants = new Map<string, number>();
for (const machine of diagram.machines) {
const first = machine.attachments[0]?.segment;
if (first) occupants.set(first, (occupants.get(first) ?? 0) + 1);
// Only a machine that sits INSIDE a lane occupies it. A gateway is counted against the
// 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 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));
};
@@ -233,13 +244,22 @@ export function toDrawio(diagram: Diagram): string {
const row = Math.floor(slot / SLOTS_PER_ROW);
x = LANE_X + 40 + column * SLOT_WIDTH;
if (spans) {
// A gateway straddles, so it sits in the gap below the highest lane it joins —
// which is what makes at-a-glance reading of "this is the way in" work.
const top = machine.attachments
.map((a) => a.segment)
.filter((name) => laneY.has(name))
.reduce((best, name) => ((laneY.get(name) ?? 0) < (laneY.get(best) ?? 0) ? name : best));
y = (laneY.get(top) ?? 80) + (laneH.get(top) ?? LANE_MIN_HEIGHT) + LANE_GAP / 2 - NODE_HEIGHT / 2;
// A gateway straddles a boundary, and WHICH boundary matters. Its first attachment
// is the segment it faces outward on; the rest are the ones it serves. Placing it
// below the outward lane put a gateway serving `home` and `devices` above an
// unrelated `cafe`, with its connection crossing a network it has nothing to do
// with. It belongs immediately above the topmost lane it actually serves.
const drawn = machine.attachments.map((a) => a.segment).filter((name) => laneY.has(name));
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 {
y = (laneY.get(first.segment) ?? 80) + 45 + row * (NODE_HEIGHT + 45);
}
@@ -266,14 +286,15 @@ export function toDrawio(diagram: Diagram): string {
.join(" · "),
});
// Badges sit along the top edge, right to left, so the first fact stated is nearest the
// resource's own corner and the row grows away from the label underneath it.
// Badges sit along the top edge and read left to right in the order the facts are
// 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) => {
cells.push({
id: `${id}-b${index}`,
value: badge.code,
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,
w: 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) ?? [];
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[] = [];
notes.push(item.type === "container" ? "container" : "virtual machine");