@squinch/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +41 -0
  3. package/dist/api.d.ts +91 -0
  4. package/dist/api.js +232 -0
  5. package/dist/browser.d.ts +3 -0
  6. package/dist/browser.js +9 -0
  7. package/dist/diff/diff.d.ts +30 -0
  8. package/dist/diff/diff.js +365 -0
  9. package/dist/fonts.generated.d.ts +1 -0
  10. package/dist/fonts.generated.js +6 -0
  11. package/dist/grammar/parser.js +22 -0
  12. package/dist/grammar/parser.terms.js +115 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/layout/layout.d.ts +197 -0
  16. package/dist/layout/layout.js +1721 -0
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.generated.d.ts +4 -0
  19. package/dist/metrics.generated.js +4 -0
  20. package/dist/metrics.js +57 -0
  21. package/dist/model/build.d.ts +8 -0
  22. package/dist/model/build.js +1343 -0
  23. package/dist/model/packs.d.ts +13 -0
  24. package/dist/model/packs.js +29 -0
  25. package/dist/model/source.d.ts +10 -0
  26. package/dist/model/source.js +28 -0
  27. package/dist/model/suggest.d.ts +2 -0
  28. package/dist/model/suggest.js +24 -0
  29. package/dist/model/types.d.ts +226 -0
  30. package/dist/model/types.js +24 -0
  31. package/dist/packs/node-fs.d.ts +1 -0
  32. package/dist/packs/node-fs.js +37 -0
  33. package/dist/packs/registry.d.ts +61 -0
  34. package/dist/packs/registry.js +122 -0
  35. package/dist/packs/sanitize.d.ts +12 -0
  36. package/dist/packs/sanitize.js +127 -0
  37. package/dist/packs/sysGlyphs.d.ts +2 -0
  38. package/dist/packs/sysGlyphs.js +21 -0
  39. package/dist/render/adaptive.d.ts +13 -0
  40. package/dist/render/adaptive.js +112 -0
  41. package/dist/render/html/runtime.d.ts +1 -0
  42. package/dist/render/html/runtime.generated.d.ts +1 -0
  43. package/dist/render/html/runtime.generated.js +6 -0
  44. package/dist/render/html/runtime.js +362 -0
  45. package/dist/render/html.d.ts +39 -0
  46. package/dist/render/html.js +235 -0
  47. package/dist/render/svg.d.ts +75 -0
  48. package/dist/render/svg.js +1403 -0
  49. package/dist/render/validate.d.ts +4 -0
  50. package/dist/render/validate.js +9 -0
  51. package/dist/themes/index.d.ts +84 -0
  52. package/dist/themes/index.js +90 -0
  53. package/dist/view/dive.d.ts +55 -0
  54. package/dist/view/dive.js +57 -0
  55. package/dist/view/navigate.d.ts +38 -0
  56. package/dist/view/navigate.js +81 -0
  57. package/dist/view/resolve.d.ts +92 -0
  58. package/dist/view/resolve.js +591 -0
  59. package/fonts/inter-400.ttf +0 -0
  60. package/fonts/inter-500.ttf +0 -0
  61. package/fonts/inter-600.ttf +0 -0
  62. package/fonts/mono-400.ttf +0 -0
  63. package/metrics.json +510 -0
  64. package/package.json +89 -0
@@ -0,0 +1,1721 @@
1
+ // ViewGraph → positioned diagram. Spike-proven architecture (docs/ENGINEERING.md):
2
+ // - declared ranks (rows/place) are ours, enforced via invisible scaffold edges
3
+ // - same-rank ("coplanar") edges bypass ELK → our deterministic coplanar router
4
+ // - one port per edge endpoint with FIXED_SIDE → ELK spreads ports + stubs
5
+ // ELK owns between-rank; we own within-rank. Keep that boundary crisp.
6
+ import ELKModule from "elkjs/lib/elk.bundled.js";
7
+ // elkjs ships a CJS class with no construct signature in its types
8
+ const ELK = ELKModule;
9
+ import { fit, measure, wrapText } from "../metrics.js";
10
+ import { resolveView } from "../view/resolve.js";
11
+ const LEAF_TIERS = [120, 160, 200, 240];
12
+ const CARD_TIERS = [200, 240, 280, 320];
13
+ const LEAF_H = 64;
14
+ /** 88 → 96: the card grew a 30px shelf along its bottom, and 66 of body is
15
+ * what the title/tagline pair needs above it (docs/design). */
16
+ const CARD_H = 96;
17
+ /** Actor tiles: a name and a caption, no plate row under them. */
18
+ const PERSON_H = 56;
19
+ /** The bordered chip holding a card's kind glyph, top-right. */
20
+ const GLYPH_CHIP = 26;
21
+ /** The shelf strip along a card's bottom: child icons, `+N`, domain chip. */
22
+ export const SHELF_H = 30;
23
+ /** How far the stacked sheets bleed past a container, right and below. Not
24
+ * added to the node's size on purpose — see the comment at `canvasExtent`. */
25
+ export const SHEET_BLEED = 8;
26
+ const PLATE = 40;
27
+ const PAD = 12;
28
+ /** The label gap, B. In a view that carries ELK labels the between-layers
29
+ * spacing drops to B and every edge gets a label, so a gap measures
30
+ * `B + B + labelHeight + B + B` — B once as layer spacing and once as
31
+ * `elk.spacing.edgeLabel`, on each side of the label. That 4×B is why
32
+ * `spacerH` subtracts it back out to keep unlabelled gaps at the density
33
+ * spacing. One knob: change this and both option value and spacer follow.
34
+ * Went 16 → 12 → 8 across three gate reviews, then to 10. */
35
+ const LABEL_GAP = 10;
36
+ /** Zone chip height (docs/design). Shared: the placer reserves it, the
37
+ * renderer fills it, and the chip straddles its border by half of it. */
38
+ export const CHIP_H = 22;
39
+ /** Room a note reserves for its leading glyph, gutter included. Shared: layout
40
+ * wraps text to what is left, the renderer draws the mark in it. */
41
+ export const NOTE_GUTTER = 19;
42
+ /** A note's widest box, and the text column inside it. The column is what the
43
+ * wrap runs against; the box is the column plus padding plus the gutter, so
44
+ * the two can never disagree about whether the text fits. */
45
+ const NOTE_MAX_W = 200;
46
+ const NOTE_TEXT_W = NOTE_MAX_W - 24 - NOTE_GUTTER;
47
+ /** The pill a label will render as, measured with the layout's font — the
48
+ * single source of truth shared by the ELK reservation (here) and the
49
+ * renderer's pill text (svg.ts). If these two ever diverge, the reservation
50
+ * is for a different pill than the one drawn. Cap 240 mirrors computePills'
51
+ * maxW; the canvas-width term is unknowable before layout and only ever
52
+ * shrinks tiny diagrams, where a slightly generous reservation is harmless. */
53
+ export function pillDims(text, font) {
54
+ const fx11 = Math.round(11 * font.scale);
55
+ const label = fit(text, 240 - 12, fx11, "400", font.metrics);
56
+ return { label, w: Math.round(measure(label, fx11, "400", font.metrics)) + 12, h: 18 };
57
+ }
58
+ /** The box a note will render as, and the lines inside it — the single source
59
+ * of truth shared by every path that places one (edge notes, layer notes, the
60
+ * candidate-ladder resolver) and by the renderer that draws it.
61
+ *
62
+ * It is one function because it was briefly two. The resolver kept its own
63
+ * copy of this arithmetic, so when notes gained a leading glyph only the
64
+ * other copy learned about the gutter: corner and `right-of` notes reserved a
65
+ * box 19px narrower than the text drawn into it, and the text ran out through
66
+ * the side. Exactly the failure `pillDims` exists to prevent, in the one
67
+ * place that had not been given the same treatment.
68
+ *
69
+ * The column narrows by the gutter and the box widens by it, so the text
70
+ * keeps its 12px right margin whatever the glyph does. */
71
+ export function noteBox(text, font) {
72
+ const fx11 = Math.round(11 * font.scale);
73
+ const lines = wrapText(text, NOTE_TEXT_W, fx11, font.metrics, 3);
74
+ // ceil the *text*, then add the padding — rounding the total instead let a
75
+ // 32.4px word round its box down and quietly spend a pixel of the right
76
+ // margin. Integers either way (DESIGN §8); this way the margin is the 12 it
77
+ // claims to be.
78
+ const widest = Math.ceil(Math.max(...lines.map((l) => measure(l, fx11, "400", font.metrics))));
79
+ const w = Math.min(NOTE_MAX_W, widest + 24 + NOTE_GUTTER);
80
+ return { lines, w, h: lines.length * 15 + 12 };
81
+ }
82
+ const SIDE_UP = { north: "NORTH", south: "SOUTH", east: "EAST", west: "WEST" };
83
+ const SIDE_DOWN = { NORTH: "north", SOUTH: "south", EAST: "east", WEST: "west" };
84
+ // Node width depends on the theme's font — the theme is a determinism input,
85
+ // so this is still a pure function of (source, theme). Both shipping themes
86
+ // use Inter at scale 1, so the parameter is currently constant; it stays
87
+ // because a theme that changed the face would change every node's width, and
88
+ // that dependency should be visible in the signature rather than assumed away.
89
+ const INTER = { metrics: "inter", scale: 1 };
90
+ function sizeOf(n, font) {
91
+ const fam = font.metrics;
92
+ const fx = (px) => Math.round(px * font.scale);
93
+ const isCard = n.kind === "card" || n.kind === "context-card";
94
+ if (isCard) {
95
+ // The text column now starts after an icon plate and ends before the
96
+ // glyph chip, so both come out of the width rather than letting the label
97
+ // run under either.
98
+ const need = PAD + PLATE + PAD + Math.max(measure(n.label, fx(15), "500", fam), measure(n.tagline ?? "", fx(11), "400", fam)) + PAD + GLYPH_CHIP + PAD;
99
+ return { w: CARD_TIERS.find((t) => t >= need) ?? CARD_TIERS[CARD_TIERS.length - 1], h: CARD_H };
100
+ }
101
+ const need = PAD + PLATE + PAD + measure(n.label, fx(13), "500", fam) + PAD;
102
+ const w = LEAF_TIERS.find((t) => t >= need) ?? LEAF_TIERS[LEAF_TIERS.length - 1];
103
+ // An actor is shorter than a service: no description line, and its round
104
+ // avatar reads at 34 where a service plate reads at 40.
105
+ return { w, h: n.kind === "person" ? PERSON_H : LEAF_H };
106
+ }
107
+ export async function layoutView(model, view, font = INTER) {
108
+ const graph = resolveView(model, view);
109
+ const diagnostics = [...graph.diagnostics];
110
+ const byPath = new Map(graph.nodes.map((n) => [n.path, n]));
111
+ const memberPaths = graph.nodes.map((n) => n.path);
112
+ const edges = graph.edges;
113
+ // Top "entities": frames count as one unit for ranking/order; framed nodes
114
+ // project to their frame — the *outermost* one, since `expand *` nests
115
+ // frames and only a top-level frame is a ranking unit. Inside frames, ELK's
116
+ // natural layering rules.
117
+ const frameParent = new Map(graph.frames.map((f) => [f.path, f.frame]));
118
+ const outermostFrame = (frame) => {
119
+ let f = frame;
120
+ for (let p = frameParent.get(f); p; p = frameParent.get(p))
121
+ f = p;
122
+ return f;
123
+ };
124
+ const entityOf = (p) => {
125
+ const frame = byPath.get(p)?.frame; // a node's immediate frame
126
+ if (frame)
127
+ return outermostFrame(frame);
128
+ return frameParent.has(p) ? outermostFrame(p) : p; // a frame is its outermost; anything else is itself
129
+ };
130
+ const entities = [
131
+ ...graph.frames.filter((f) => !f.frame).map((f) => f.path),
132
+ ...graph.nodes.filter((n) => !n.frame).map((n) => n.path),
133
+ ];
134
+ const entitySet = new Set(entities);
135
+ // ── zones (SPEC §Zones): cross-cutting boundaries → ELK compounds ────────
136
+ // A zone's effective members in this view are the visible entities matching
137
+ // any declared member path (exactly, or as a descendant). Zones must form a
138
+ // clean hierarchy — with each other AND with expanded frames — so the whole
139
+ // picture stays a tree ELK can lay out; like frames, a zone is one unit for
140
+ // ranking and ELK layers freely inside it.
141
+ const memberMatch = (path, member) => path === member || path.startsWith(member + ".");
142
+ const zones = [];
143
+ for (const z of model.zones) {
144
+ const set = new Set(entities.filter((e) => z.members.some((m) => memberMatch(e, m))));
145
+ for (const n of graph.nodes) {
146
+ // any enclosing frame in the zone keeps the node whole — under
147
+ // `expand *` the ranked entity is the outermost frame, so that is the
148
+ // one membership must cover, and the one the error should name
149
+ if (!n.frame || set.has(outermostFrame(n.frame)))
150
+ continue;
151
+ if (z.members.some((m) => memberMatch(n.path, m)))
152
+ diagnostics.push({
153
+ severity: "error",
154
+ message: `zone \`${z.id}\` cuts through expanded container \`${outermostFrame(n.frame)}\` (member \`${n.path}\`)`,
155
+ fix: `contain the whole container (\`contains ${outermostFrame(n.frame)}\`), or don't expand it in this view`,
156
+ loc: z.loc,
157
+ });
158
+ }
159
+ if (set.size > 0)
160
+ zones.push({
161
+ id: z.id, label: z.label ?? z.id, kind: z.kind,
162
+ icon: z.icon, labelPos: z.labelPos, color: z.color, detail: z.detail, set,
163
+ });
164
+ else if (!view.auto)
165
+ // A zone follows visibility by design, but vanishing in silence is not
166
+ // the same thing: the author asked for this boundary in a view they
167
+ // wrote, so say why it is not there (SPEC §6: never silently dropped).
168
+ diagnostics.push({
169
+ severity: "warning",
170
+ message: `zone \`${z.id}\` has no visible members in view \`${view.name}\``,
171
+ fix: z.members.length
172
+ ? `its members (${z.members.join(", ")}) are inside collapsed cards here — ` +
173
+ `\`expand\` one, or scope the view to them`
174
+ : "the zone contains nothing",
175
+ loc: z.loc,
176
+ });
177
+ }
178
+ for (let i = 0; i < zones.length; i++)
179
+ for (let j = i + 1; j < zones.length; j++) {
180
+ const A = zones[i], B = zones[j];
181
+ const shared = [...A.set].filter((e) => B.set.has(e));
182
+ if (!shared.length)
183
+ continue;
184
+ const aOnly = [...A.set].filter((e) => !B.set.has(e));
185
+ const bOnly = [...B.set].filter((e) => !A.set.has(e));
186
+ if (aOnly.length && bOnly.length)
187
+ diagnostics.push({
188
+ severity: "error",
189
+ message: `zones \`${A.id}\` and \`${B.id}\` partially overlap — visible zones must nest or stay disjoint`,
190
+ fix: `shared: ${shared.join(", ")} · only ${A.id}: ${aOnly.join(", ")} · only ${B.id}: ${bOnly.join(", ")}`,
191
+ loc: view.loc,
192
+ });
193
+ // Identical member sets pass the partial-overlap test (nothing is
194
+ // exclusive to either) and then break the nesting pass, which orders
195
+ // zones by strict containment: neither can be the other's parent, so one
196
+ // ends up with no geometry and its members render outside it. Found by a
197
+ // generated-input spike; no hand-written diagram had ever declared the
198
+ // same boundary twice.
199
+ else if (!aOnly.length && !bOnly.length)
200
+ diagnostics.push({
201
+ severity: "error",
202
+ message: `zones \`${A.id}\` and \`${B.id}\` contain exactly the same members`,
203
+ fix: `one boundary cannot sit inside the other: merge them, or give one a narrower \`contains\``,
204
+ loc: view.loc,
205
+ });
206
+ }
207
+ // nesting: parent = smallest strictly-containing zone; entity → smallest zone
208
+ const zoneParent = new Map();
209
+ for (const z of zones) {
210
+ let parent;
211
+ for (const cand of zones) {
212
+ if (cand === z || cand.set.size <= z.set.size)
213
+ continue;
214
+ if (![...z.set].every((e) => cand.set.has(e)))
215
+ continue;
216
+ if (!parent || cand.set.size < parent.set.size)
217
+ parent = cand;
218
+ }
219
+ zoneParent.set(z.id, parent);
220
+ }
221
+ const entityZone = new Map();
222
+ for (const e of entities) {
223
+ let best;
224
+ for (const z of zones)
225
+ if (z.set.has(e) && (!best || z.set.size < best.set.size))
226
+ best = z;
227
+ if (best)
228
+ entityZone.set(e, best);
229
+ }
230
+ const outerZoneOf = (e) => {
231
+ let z = entityZone.get(e);
232
+ while (z && zoneParent.get(z.id))
233
+ z = zoneParent.get(z.id);
234
+ return z?.id;
235
+ };
236
+ // the ranking/order granularity: outermost zone, else frame, else the node
237
+ const unitOf = (p) => outerZoneOf(entityOf(p)) ?? entityOf(p);
238
+ const units = [
239
+ ...zones.filter((z) => !zoneParent.get(z.id)).map((z) => z.id),
240
+ ...entities.filter((e) => !entityZone.has(e)),
241
+ ];
242
+ const unitSet = new Set(units);
243
+ // ── ranks: declared (rows/place) pinned; everything else floats around them.
244
+ // Context cards arrive *above* the scope's first row, so unhinted nodes may
245
+ // take negative ranks and the whole grid is normalized afterwards. Declared
246
+ // rows are relative to each other — context must never push them down.
247
+ const declared = new Map();
248
+ view.layout.rows?.forEach((row, i) => row.forEach((p) => unitSet.has(unitOf(p)) && declared.set(unitOf(p), i)));
249
+ // `place`'s direction word decides whether the node changes band or just its
250
+ // position within one, and which axis is which depends on `direction`:
251
+ // laying out downward, ranks are rows, so above/below move between them and
252
+ // left-of/right-of order within one. Laying out to the right, ranks are
253
+ // columns and the pairs swap. (Before this, relpos was parsed, validated and
254
+ // documented — then never read: all four directions produced byte-identical
255
+ // output, so `place x left-of y` silently meant right-of.)
256
+ const downward = view.layout.direction !== "right";
257
+ const changesBand = (rp) => downward ? rp === "above" || rp === "below" : rp === "left-of" || rp === "right-of";
258
+ const towardsStart = (rp) => rp === "above" || rp === "left-of";
259
+ const crossEdges = edges
260
+ .map((e) => [unitOf(e.from), unitOf(e.to)])
261
+ .filter(([a, b]) => a !== b);
262
+ /** Relax until stable: successors sit below predecessors; unpinned
263
+ * predecessors of a pinned node float above it (possibly negative). */
264
+ const relax = (pins) => {
265
+ const out = new Map(pins);
266
+ for (let pass = 0; pass < units.length + 2; pass++) {
267
+ let changed = false;
268
+ for (const [a, b] of crossEdges) {
269
+ const ra = out.get(a), rb = out.get(b);
270
+ if (!pins.has(b)) {
271
+ const want = Math.max(rb ?? 0, (ra ?? 0) + 1);
272
+ if (want !== rb) {
273
+ out.set(b, want);
274
+ changed = true;
275
+ }
276
+ }
277
+ if (!pins.has(a)) {
278
+ const cap = (out.get(b) ?? 0) - 1;
279
+ const want = rb !== undefined ? Math.min(ra ?? cap, cap) : ra ?? 0;
280
+ if (want !== ra) {
281
+ out.set(a, want);
282
+ changed = true;
283
+ }
284
+ }
285
+ }
286
+ if (!changed)
287
+ break;
288
+ }
289
+ for (const p of units)
290
+ if (!out.has(p))
291
+ out.set(p, 0);
292
+ return out;
293
+ };
294
+ // `place` needs its target's rank — but that only existed for targets pinned
295
+ // by `rows`, so `place x below y` where y wasn't in a rows band was silently
296
+ // discarded, and in a diagram with no `rows` at all *every* place did nothing.
297
+ // A target's natural rank is knowable, it just isn't known yet: relax once to
298
+ // learn it, pin the placed nodes against that, then relax again.
299
+ const beforePlace = relax(declared);
300
+ for (const pl of view.layout.place) {
301
+ // Already-pinned first, so places chain: `place idx right-of sync` has to
302
+ // see where the *previous* `place sync right-of db` put sync, not sync's
303
+ // natural rank. The relaxed value is only the fallback, for a target
304
+ // nothing has pinned.
305
+ const t = declared.get(unitOf(pl.target)) ?? beforePlace.get(unitOf(pl.target));
306
+ if (t === undefined)
307
+ continue;
308
+ declared.set(unitOf(pl.node), changesBand(pl.relpos) ? t + (towardsStart(pl.relpos) ? -1 : 1) : t);
309
+ }
310
+ // Ranking granularity is the outermost zone (`unitOf`), so nodes sharing one
311
+ // collapse to a single unit and any rank hint between them is discarded —
312
+ // along with the "runs upward" check, which would be comparing a unit against
313
+ // itself. Wrapping a diagram in one boundary is the normal shape for a cloud
314
+ // estate, and it turned `rows` into dead code that reported nothing: a whole
315
+ // `layout` block rendering byte-identical to no block at all. Hints may be
316
+ // unimplementable here, but they must never be silent (SPEC §9).
317
+ {
318
+ const zoneIds = new Set(zones.map((z) => z.id));
319
+ // Naming a zone member in `rows` is *not* automatically inert: the rank is
320
+ // recorded against the member's unit, which is the zone, so naming one
321
+ // member is exactly how you rank the whole zone against everything outside
322
+ // it — and that works. It only goes nowhere when the same zone is named
323
+ // from more than one band, because the second write overwrites the first
324
+ // and the ordering that was being asked for is the one thing ELK decides
325
+ // internally. Warning on the single-member case told people a hint they
326
+ // could see working had no effect.
327
+ const bandsPerZone = new Map();
328
+ (view.layout.rows ?? []).forEach((row, i) => row.forEach((p) => {
329
+ const zone = unitOf(p);
330
+ if (zone === entityOf(p) || !zoneIds.has(zone))
331
+ return;
332
+ const bands = bandsPerZone.get(zone) ?? new Map();
333
+ bandsPerZone.set(zone, bands);
334
+ (bands.get(i) ?? bands.set(i, new Set()).get(i)).add(p);
335
+ }));
336
+ const inertRows = [...bandsPerZone.values()]
337
+ .filter((bands) => bands.size > 1)
338
+ .flatMap((bands) => [...bands.values()].flatMap((s) => [...s]));
339
+ const inertPlace = view.layout.place
340
+ .filter((pl) => unitOf(pl.node) === unitOf(pl.target) && zoneIds.has(unitOf(pl.node)))
341
+ .map((pl) => pl.node);
342
+ const stuck = [...new Set([...inertRows, ...inertPlace])];
343
+ if (stuck.length) {
344
+ const zone = unitOf(stuck[0]);
345
+ diagnostics.push({
346
+ severity: "warning",
347
+ message: `rank hints on ${stuck.map((p) => `\`${p}\``).join(", ")} have no effect — ` +
348
+ `zone \`${zone}\` is laid out as one block, and its members are ranked inside it by ELK`,
349
+ fix: `rows/cols/place order things *between* zones, not within one. ` +
350
+ `Name a single member to rank the zone as a whole, ` +
351
+ `or drop the boundary if the order inside it matters more.`,
352
+ loc: view.loc,
353
+ });
354
+ }
355
+ }
356
+ // second pass, now that `place` has pinned what it resolved against
357
+ const rank = relax(declared);
358
+ // normalize to 0-based
359
+ const minRank = Math.min(...rank.values());
360
+ if (minRank !== 0)
361
+ for (const [k, v] of rank)
362
+ rank.set(k, v - minRank);
363
+ // model order over units: rows first, placed after targets, rest in resolve order
364
+ const order = [];
365
+ for (const p of (view.layout.rows ?? []).flat().map(unitOf))
366
+ if (unitSet.has(p) && !order.includes(p))
367
+ order.push(p);
368
+ // Everything else in resolve order *before* `place` runs, so a placed node
369
+ // can be moved next to its target even when neither is in a `rows` band.
370
+ // Seeding only from rows meant `indexOf(target)` was -1 in a diagram without
371
+ // rows, and the reorder was skipped — so `left-of` did nothing there.
372
+ for (const p of units)
373
+ if (!order.includes(p))
374
+ order.push(p);
375
+ for (const pl of view.layout.place) {
376
+ const n = unitOf(pl.node);
377
+ const from = order.indexOf(n);
378
+ if (from < 0)
379
+ continue;
380
+ order.splice(from, 1);
381
+ const i = order.indexOf(unitOf(pl.target));
382
+ if (i < 0) {
383
+ order.splice(from, 0, n);
384
+ continue;
385
+ } // target not here: leave it be
386
+ // Within a band, model order *is* left-to-right, so `left-of` has to land
387
+ // before its target. Across bands (above/below) the node sits in a
388
+ // different row entirely and this only decides which column it lands near,
389
+ // so keep it adjacent to the target.
390
+ order.splice(!changesBand(pl.relpos) && towardsStart(pl.relpos) ? i : i + 1, 0, n);
391
+ }
392
+ // `cols` pins horizontal bands: members of an earlier column sit left of a
393
+ // later one. Only columned units move — the slots they occupy in the model
394
+ // order are re-filled in column order, so everything else keeps its place.
395
+ if (view.layout.cols?.length) {
396
+ const colOf = new Map();
397
+ view.layout.cols.forEach((col, i) => col.forEach((p) => colOf.set(unitOf(p), i)));
398
+ const slots = [];
399
+ order.forEach((p, i) => { if (colOf.has(p))
400
+ slots.push(i); });
401
+ const columned = slots.map((i) => order[i])
402
+ .sort((a, b) => colOf.get(a) - colOf.get(b) || order.indexOf(a) - order.indexOf(b));
403
+ slots.forEach((slot, i) => { order[slot] = columned[i]; });
404
+ }
405
+ // ── edge classes: inner (same entity) | coplanar (same rank, both bare) |
406
+ // cross-rank (ELK's) ────────────────────────────────────────────────────
407
+ const inner = (e) => entityOf(e.from) === entityOf(e.to) && byPath.get(e.from)?.frame;
408
+ // The coplanar router handles bare leaves, leaves inside expanded frames,
409
+ // and expanded frames themselves — a framed endpoint routes wall-to-wall
410
+ // between the outermost frame rects (docs/notes/coplanar.md, approach #5).
411
+ // Zone units stay out: a zone lays out as one block and its dashed boundary
412
+ // is not a wall a wire can enter, so those edges go to ELK with the warning
413
+ // below. Classifying an edge coplanar (hiding it from ELK) IS the entire
414
+ // same-rank mechanism — there is no other way to co-layer its units.
415
+ const framePathSet = new Set(graph.frames.map((f) => f.path));
416
+ const routable = (p) => byPath.has(p) || framePathSet.has(p);
417
+ const coplanar = edges.filter((e) => !inner(e) &&
418
+ routable(e.from) &&
419
+ routable(e.to) &&
420
+ !outerZoneOf(entityOf(e.from)) &&
421
+ !outerZoneOf(entityOf(e.to)) &&
422
+ unitOf(e.from) !== unitOf(e.to) &&
423
+ rank.get(unitOf(e.from)) === rank.get(unitOf(e.to)));
424
+ const coplanarSet = new Set(coplanar.map((e) => e.id));
425
+ for (const e of edges) {
426
+ if (!inner(e) && !coplanarSet.has(e.id) &&
427
+ unitOf(e.from) !== unitOf(e.to) &&
428
+ rank.get(unitOf(e.from)) === rank.get(unitOf(e.to)))
429
+ diagnostics.push({
430
+ severity: "warning",
431
+ message: `same-rank edge ${e.from} → ${e.to} involves a zone — the router cannot cross a zone boundary, so the row may not hold`,
432
+ fix: `give the zone its own band in \`rows\`, or drop one end from the zone`,
433
+ loc: view.loc,
434
+ });
435
+ }
436
+ const elkEdges = edges.filter((e) => !coplanarSet.has(e.id));
437
+ const natural = new Map(units.map((p) => [p, 0]));
438
+ for (let i = 0; i < units.length; i++)
439
+ for (const e of elkEdges) {
440
+ const ef = unitOf(e.from), et = unitOf(e.to);
441
+ if (ef !== et)
442
+ natural.set(et, Math.max(natural.get(et), natural.get(ef) + 1));
443
+ }
444
+ /** The `rows` line the author could paste, with `target` moved into
445
+ * `source`'s band — or undefined when that would break a different edge. */
446
+ const mergedRowsFix = (source, target) => {
447
+ const moved = new Map(declared);
448
+ moved.set(target, declared.get(source));
449
+ for (const other of edges) {
450
+ const oa = unitOf(other.from), ob = unitOf(other.to);
451
+ if (oa === ob || !moved.has(oa) || !moved.has(ob))
452
+ continue;
453
+ if (moved.get(oa) > moved.get(ob))
454
+ return undefined;
455
+ }
456
+ const bands = new Map();
457
+ for (const [unit, row] of moved) {
458
+ if (!bands.has(row))
459
+ bands.set(row, []);
460
+ bands.get(row).push(unit);
461
+ }
462
+ const line = [...bands.keys()].sort((x, y) => x - y)
463
+ .map((row) => `[${bands.get(row).join(" ")}]`)
464
+ .join(" ");
465
+ return `write \`rows ${line}\` — equal ranks are legal, and route side to side`;
466
+ };
467
+ // A conflict is only a *user* error when two explicitly declared nodes
468
+ // contradict each other — an edge that runs upward between declared rows.
469
+ // Context and other unhinted nodes never trigger it; they float (see above).
470
+ for (const e of edges) {
471
+ const a = unitOf(e.from), b = unitOf(e.to);
472
+ if (a === b || !declared.has(a) || !declared.has(b))
473
+ continue;
474
+ if (declared.get(a) > declared.get(b)) {
475
+ // equal ranks are legal (coplanar). Name the *unit* in the fix, not the
476
+ // endpoint: ranking is per unit, so when an endpoint sits in a zone the
477
+ // thing the author can actually move is the zone, and telling them to put
478
+ // a zone member in a row is advice the language does not accept.
479
+ const named = (endpoint, unit) => endpoint === unit ? `\`${endpoint}\`` : `\`${unit}\` (which holds \`${endpoint}\`)`;
480
+ diagnostics.push({
481
+ severity: "error",
482
+ message: `hint conflict: \`${e.from}\` → \`${e.to}\` runs upward — row ${declared.get(a)} to row ${declared.get(b)}`,
483
+ // Round 17: 27-rank-conflict hit the *same* conflict on two consecutive
484
+ // attempts, so the edit between them addressed nothing — the agent was
485
+ // guessing at an arrangement rather than applying a fix. Every
486
+ // diagnostic here that gets applied in one iteration writes the
487
+ // corrected text out (`write \`[a b c]\``); the ones that describe a
488
+ // choice between two abstract edits are the ones that get guessed at.
489
+ // So: offer the concrete line. Merging the target into the source's
490
+ // band is always legal on its own (equal ranks route side to side) —
491
+ // but it can collide with a *different* edge, so it is only offered
492
+ // when the whole arrangement comes back clean. A confidently wrong
493
+ // suggestion is worse than a vague right one.
494
+ fix: mergedRowsFix(a, b) ??
495
+ `put ${named(e.to, b)} in a row below ${named(e.from, a)}, or drop one of them from \`rows\``,
496
+ loc: view.loc,
497
+ });
498
+ }
499
+ }
500
+ const scaffold = [];
501
+ for (const p of order) {
502
+ const want = rank.get(p);
503
+ const nat = natural.get(p);
504
+ if (nat < want) {
505
+ const feeder = order.find((f) => rank.get(f) === want - 1);
506
+ if (feeder)
507
+ scaffold.push({ id: `scaffold.${p}`, from: feeder, to: p });
508
+ }
509
+ }
510
+ // route hints: exact (from|to|label) beats pairwise (from|to); a pairwise hint
511
+ // on an ambiguous parallel pair is a check error per SPEC §4.
512
+ const routeExact = new Map(view.layout.routes.filter((r) => r.label).map((r) => [`${r.from}|${r.to}|${r.label}`, r]));
513
+ const routePair = new Map(view.layout.routes.filter((r) => !r.label).map((r) => [`${r.from}|${r.to}`, r]));
514
+ const pairCount = new Map();
515
+ for (const e of edges) {
516
+ const k = `${e.from}|${e.to}`;
517
+ pairCount.set(k, (pairCount.get(k) ?? 0) + 1);
518
+ }
519
+ for (const r of view.layout.routes) {
520
+ if (!r.label && (pairCount.get(`${r.from}|${r.to}`) ?? 0) > 1)
521
+ diagnostics.push({
522
+ severity: "error",
523
+ message: `${pairCount.get(`${r.from}|${r.to}`)} edges match \`route ${r.from} -> ${r.to}\``,
524
+ fix: "add the edge's label to the route statement to disambiguate",
525
+ loc: r.loc,
526
+ });
527
+ // Sides are fed to ELK. Same-rank edges bypass ELK entirely for our own
528
+ // coplanar router, which picks its own sides from geometry — so a `from`/`to`
529
+ // hint on one is accepted, validated, and then discarded. That is the silent
530
+ // class this project keeps having to fix, and the canonical example shipped
531
+ // with one: `route db ~> sync from east to west` in examples/orders.squinch,
532
+ // taught by the cookbook, doing nothing.
533
+ if ((r.fromSide || r.toSide) && coplanar.some((e) => e.from === r.from && e.to === r.to))
534
+ diagnostics.push({
535
+ severity: "warning",
536
+ message: `\`route ${r.from} -> ${r.to}\` sides are ignored — it is a same-rank edge`,
537
+ fix: "same-rank edges are routed side-to-side automatically; drop the `from`/`to`, "
538
+ + "or move one end to another row so the edge spans ranks",
539
+ loc: r.loc,
540
+ });
541
+ }
542
+ // Which sides an edge leaves and enters by default. This used to answer
543
+ // south/north unconditionally, so a `direction right` diagram — laid out in
544
+ // columns — had every edge forced out of the bottom of one box and into the
545
+ // top of the next, jogging vertically across a gap meant to be crossed
546
+ // sideways. `20-multicloud-migration` came out 456px tall for a graph that
547
+ // fits in 288, and its stubs had nowhere to run, which is where two of the
548
+ // five DESIGN §4 violations came from.
549
+ const flowsRight = view.layout.direction === "right";
550
+ const sidesOf = (e) => {
551
+ const hint = routeExact.get(`${e.from}|${e.to}|${e.label}`) ?? routePair.get(`${e.from}|${e.to}`);
552
+ const forward = rank.get(unitOf(e.from)) <= rank.get(unitOf(e.to));
553
+ const [out, into] = flowsRight
554
+ ? forward ? ["east", "west"] : ["west", "east"]
555
+ : forward ? ["south", "north"] : ["north", "south"];
556
+ return { from: hint?.fromSide ?? out, to: hint?.toSide ?? into };
557
+ };
558
+ // Coplanar label reservation, straight case (phase 2). A labelled same-rank
559
+ // pair needs its in-layer gutter to be at least the pill plus breathing room,
560
+ // and `elk.spacing.individual` honours exactly that (spiked: 48 → 120 on
561
+ // request) — so even the router's edges get their space from ELK where ELK
562
+ // owns the dimension. The left node of the pair (model order, which
563
+ // forceNodeModelOrder makes the in-layer order) carries the override. A pair
564
+ // that ends up routed around a blocker wastes a little width here; the lane
565
+ // itself is below the band, where width is free.
566
+ // ── flow badges are part of the label reservation ────────────────────────
567
+ // A badge used to be placed after layout: docked to the left of its pill, or
568
+ // walked out from the edge's start until it stopped overlapping something.
569
+ // Both fail the same way — the badge is positioned relative to the *pill*, or
570
+ // to nothing, and never to its own wire. `microservices#checkout` had one
571
+ // land flush between two pills (so it read as the wrong edge's number) and
572
+ // another land on a neighbouring async wire; neither overlapped anything
573
+ // `checkLayout` asserts, because the failure is attachment, not collision.
574
+ // So a badge is reserved together with its pill, as one rect on one wire, and
575
+ // the annotation pass carves it back out. Sized from the FULL flow's numbers,
576
+ // so walking a flow one hop at a time still cannot change the geometry.
577
+ const BADGE_GAP = 4;
578
+ const fx = (px) => Math.round(px * font.scale);
579
+ const badgeW = (edgeId) => {
580
+ const nums = graph.flow?.byEdge[edgeId];
581
+ if (!nums?.length)
582
+ return 0;
583
+ return Math.max(9, Math.round(measure(nums.join("·"), fx(10), "500", font.metrics) / 2) + 5) * 2;
584
+ };
585
+ const coplanarGutter = new Map();
586
+ for (const e of coplanar) {
587
+ if (!e.label)
588
+ continue;
589
+ const bw = badgeW(e.id);
590
+ // frame pairs reserve extra: their anchors come from interior leaves, so
591
+ // the run may jog at mid-gutter and the pill needs clearance past the jog
592
+ const framePair = unitOf(e.from) !== e.from || unitOf(e.to) !== e.to;
593
+ const need = pillDims(e.label, font).w + (bw ? bw + BADGE_GAP : 0) + (framePair ? 32 : 16);
594
+ const [a, b] = [unitOf(e.from), unitOf(e.to)];
595
+ const left = order.indexOf(a) <= order.indexOf(b) ? a : b;
596
+ coplanarGutter.set(left, Math.max(coplanarGutter.get(left) ?? 0, need));
597
+ }
598
+ const leafChild = (p) => {
599
+ const n = byPath.get(p);
600
+ const { w, h } = sizeOf(n, font);
601
+ const ports = elkEdges.flatMap((e) => {
602
+ const s = sidesOf(e);
603
+ const out = [];
604
+ if (e.from === p)
605
+ out.push({ id: `${e.id}.src`, width: 0, height: 0, layoutOptions: { "elk.port.side": SIDE_UP[s.from] } });
606
+ if (e.to === p)
607
+ out.push({ id: `${e.id}.dst`, width: 0, height: 0, layoutOptions: { "elk.port.side": SIDE_UP[s.to] } });
608
+ return out;
609
+ });
610
+ const gutter = coplanarGutter.get(p);
611
+ return {
612
+ id: p, width: w, height: h, ports,
613
+ layoutOptions: {
614
+ "elk.portConstraints": "FIXED_SIDE",
615
+ ...(gutter ? { "elk.spacing.individual": `elk.spacing.nodeNode:${gutter}` } : {}),
616
+ },
617
+ };
618
+ };
619
+ const frameLabels = new Map(graph.frames.map((f) => [f.path, f.label]));
620
+ const frameColors = new Map(graph.frames.map((f) => [f.path, f.color]));
621
+ // Child frames recurse through entityElk (defined below — mutual recursion
622
+ // is safe here because nothing invokes either until the ELK graph is built),
623
+ // so an `expand *` ladder reaches ELK as real nested compounds rather than
624
+ // the childless 0×0 leaves the one-level rule used to guard against.
625
+ const framedChildren = (framePath) => [
626
+ ...graph.frames.filter((f) => f.frame === framePath).map((f) => entityElk(f.path)),
627
+ ...graph.nodes.filter((n) => n.frame === framePath).map((n) => leafChild(n.path)),
628
+ ];
629
+ const density = view.layout.density ?? "comfortable";
630
+ // On the 8px grid (DESIGN §2), and the ladder is now regular: each step is
631
+ // +8 between the two spacings. `spacious` was [72,84] — the only rung where
632
+ // the gap was 12, and the only one off the grid.
633
+ const SP = { compact: [32, 40], comfortable: [48, 56], spacious: [72, 80] }[density];
634
+ // Only views that carry a cross-rank label pay the spacer scheme at all: a
635
+ // label-free view keeps the classic spacing with zero dummies, and stays
636
+ // byte-identical to what it rendered before any of this existed. (Gate 1b
637
+ // taught this the hard way — spacers on a label-free canonical example split
638
+ // its routing channels around dummy layers and inflated it 26%.)
639
+ // Edge-anchored notes are layout citizens (the first of the note anchors to
640
+ // become one): `note on a -> b` rides its own edge into ELK as a second
641
+ // inline label, so the gap is sized for the pill *and* the note and the two
642
+ // can never fight over the midpoint — which is exactly where both used to be
643
+ // placed. The other anchors stay on the render-time resolver: `right-of`/
644
+ // `left-of` provably cannot be side-controlled in ELK (comment boxes ignore
645
+ // port sides; in-layer edges re-rank), corners are canvas chrome, and
646
+ // `above`/`below` would insert whole layers — deferred, recorded in
647
+ // docs/notes/note-placement.md.
648
+ const noteDims = (text) => noteBox(text, font);
649
+ const edgeNotes = [];
650
+ // `above`/`below` on a bare anchor join the layout as layer nodes: a plain
651
+ // node with a directed invisible edge (note→anchor places it above,
652
+ // anchor→note below — spiked before adoption). The note gets a real layer,
653
+ // so it can never collide and the next rank makes room instead of being
654
+ // dodged around. Framed/zoned anchors keep the resolver: a note node inside
655
+ // a compound would join the compound's layering, which is not what a
656
+ // human-facing annotation should do to a container's interior.
657
+ const layerNotes = [];
658
+ (view.notes ?? []).forEach((note, i) => {
659
+ if (note.anchor.kind === "edge") {
660
+ const e = edges.find((x) => x.from === note.anchor.from && x.to === note.anchor.to);
661
+ if (e)
662
+ edgeNotes.push({ i, edgeId: e.id, ...noteDims(note.text) });
663
+ return;
664
+ }
665
+ if (note.anchor.kind !== "relpos")
666
+ return;
667
+ const rp = note.anchor.relpos;
668
+ if (rp !== "above" && rp !== "below")
669
+ return;
670
+ const target = note.anchor.target;
671
+ const n = byPath.get(target);
672
+ if (!n || n.frame)
673
+ return;
674
+ layerNotes.push({ i, id: `note:${i}`, anchor: target, above: rp === "above", ...noteDims(note.text) });
675
+ });
676
+ // Badges deliberately do NOT switch this on. A flow view with no labels at
677
+ // all has nothing for a badge to collide with, and its badge sits at the
678
+ // midpoint of its own run; turning the scheme on just to anchor one rewrote
679
+ // the layout of `12-flow-checkout`, which had no pills in it to begin with.
680
+ const hasElkLabels = elkEdges.some((e) => !!e.label) || edgeNotes.length > 0 || layerNotes.length > 0;
681
+ const entityElk = (p) => frameLabels.has(p)
682
+ ? {
683
+ id: p,
684
+ layoutOptions: {
685
+ // Off the grid on purpose, like the zone padding below: 44 is what
686
+ // seats the frame's title against its top border. 48 pushes the
687
+ // contents down without moving the title, so the band above the
688
+ // first row just reads as slack.
689
+ "elk.padding": "[top=44,left=16,bottom=16,right=16]",
690
+ // A labelled frame-coplanar edge widens the gutter to its pill,
691
+ // exactly as leafChild does — spiked: elk.spacing.individual is
692
+ // honoured on a compound under INCLUDE_CHILDREN (coplanar.md #5)
693
+ ...(coplanarGutter.get(p)
694
+ ? { "elk.spacing.individual": `elk.spacing.nodeNode:${coplanarGutter.get(p)}` }
695
+ : {}),
696
+ "elk.spacing.nodeNode": "32",
697
+ "elk.layered.spacing.nodeNodeBetweenLayers": hasElkLabels ? String(LABEL_GAP) : "40",
698
+ // Edge spacing has to be repeated on every compound. ELK does not
699
+ // inherit it from the root, and its own default is 10 — below the
700
+ // 16 DESIGN §4 requires, and below the 2×R_EDGE at which a corner
701
+ // reaches full radius, so a 10px stub renders as ~5px of straight
702
+ // line and reads as a diagonal escape from the box. Every stub
703
+ // violation in the corpus was an edge routed inside a zone or an
704
+ // expanded frame, falling through to that default.
705
+ "elk.layered.spacing.edgeNodeBetweenLayers": "24",
706
+ "elk.spacing.edgeNode": "24",
707
+ // labels are layout citizens (see the elkEdges map) — repeated in every
708
+ // bag for the same reason the edge spacing is: ELK does not inherit
709
+ "elk.edgeLabels.inline": "true",
710
+ "elk.spacing.edgeLabel": String(LABEL_GAP),
711
+ },
712
+ children: framedChildren(p),
713
+ }
714
+ : leafChild(p);
715
+ // zone compound: child zones (declaration order) then direct entities
716
+ // (resolve order) — both deterministic. Inherits the view's density.
717
+ const zoneElk = (z) => ({
718
+ id: z.id,
719
+ layoutOptions: {
720
+ // Deliberately off the 8px grid, and staying that way. 28/20 encodes a
721
+ // proportion — the band above the contents is one notch more than the
722
+ // sides, enough to seat the label chip without the boundary reading
723
+ // top-heavy. Rounding them individually onto the grid (32/16) doubles
724
+ // that gap to 16 and the zone goes lopsided: on-grid, proportionally
725
+ // wrong. DESIGN §2's rule is about numbers chosen from a deliberate
726
+ // scale, not arithmetic for its own sake, and this pair is the scale.
727
+ "elk.padding": "[top=28,left=20,bottom=20,right=20]",
728
+ "elk.spacing.nodeNode": String(SP[0]),
729
+ "elk.layered.spacing.nodeNodeBetweenLayers": hasElkLabels ? String(LABEL_GAP) : String(SP[1]),
730
+ // see entityElk: ELK does not inherit edge spacing into a compound
731
+ "elk.layered.spacing.edgeNodeBetweenLayers": "24",
732
+ "elk.spacing.edgeNode": "24",
733
+ // labels are layout citizens (see the elkEdges map) — repeated in every
734
+ // bag for the same reason the edge spacing is: ELK does not inherit
735
+ "elk.edgeLabels.inline": "true",
736
+ "elk.spacing.edgeLabel": String(LABEL_GAP),
737
+ },
738
+ children: [
739
+ ...zones.filter((c) => zoneParent.get(c.id) === z).map(zoneElk),
740
+ ...entities.filter((e) => entityZone.get(e) === z).map(entityElk),
741
+ ],
742
+ });
743
+ const zoneById = new Map(zones.map((z) => [z.id, z]));
744
+ // Gap arithmetic: see LABEL_GAP. A labelled gap costs 4×B around the label,
745
+ // so an unlabelled one needs a spacer of (density - 4B) to come out at the
746
+ // same place; a frame's tighter interior (historically 40) is the same sum
747
+ // against 40. A real pill is 18 tall and is drawn centred inside whatever was
748
+ // reserved, so labelled gaps only exceed the standard where 18 + 4B beats the
749
+ // density spacing — compact only.
750
+ const spacerH = (inFrame) => Math.max(2, (inFrame ? 40 : SP[1]) - 4 * LABEL_GAP);
751
+ const labelFor = (e) => {
752
+ const inFrame = !!byPath.get(e.from)?.frame && byPath.get(e.from)?.frame === byPath.get(e.to)?.frame;
753
+ const bw = badgeW(e.id);
754
+ // An unlabelled edge reserves nothing extra: its badge draws centred *on*
755
+ // the wire, where the 2px spacer already sits, and the wire's own clearance
756
+ // (edgeNode / edgeEdge) is the room it needs. Widening the spacer instead
757
+ // pushed the badge off to one side and rearranged whole flow diagrams that
758
+ // have no pills at all — paying layout for a bead that sat fine on the line.
759
+ if (!e.label)
760
+ return { text: " ", width: 2, height: spacerH(inFrame) };
761
+ return {
762
+ text: e.label,
763
+ width: pillDims(e.label, font).w + (bw ? bw + BADGE_GAP : 0),
764
+ height: Math.max(18, spacerH(inFrame)),
765
+ };
766
+ };
767
+ const children = order.map((p) => (zoneById.has(p) ? zoneElk(zoneById.get(p)) : entityElk(p)));
768
+ for (const n of layerNotes)
769
+ children.push({ id: n.id, width: n.w, height: n.h, layoutOptions: {} });
770
+ const elkGraph = {
771
+ id: "root",
772
+ layoutOptions: {
773
+ "elk.algorithm": "layered",
774
+ "elk.direction": view.layout.direction === "right" ? "RIGHT" : "DOWN",
775
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
776
+ "elk.layered.crossingMinimization.forceNodeModelOrder": "true",
777
+ "elk.edgeRouting": "ORTHOGONAL",
778
+ "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
779
+ "elk.spacing.nodeNode": String(SP[0]),
780
+ "elk.layered.spacing.nodeNodeBetweenLayers": hasElkLabels ? String(LABEL_GAP) : String(SP[1]),
781
+ "elk.layered.spacing.edgeNodeBetweenLayers": "24",
782
+ "elk.spacing.edgeNode": "24",
783
+ // labels are layout citizens (see the elkEdges map) — repeated in every
784
+ // bag for the same reason the edge spacing is: ELK does not inherit
785
+ "elk.edgeLabels.inline": "true",
786
+ "elk.spacing.edgeLabel": String(LABEL_GAP),
787
+ "elk.spacing.edgeEdge": "16",
788
+ "elk.padding": "[top=32,left=32,bottom=32,right=32]",
789
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
790
+ // spiked: MEDIAN_LAYER puts the label mid-dogleg, closest to the old
791
+ // nine-fraction midpoint aesthetic (TAIL hugs the source, HEAD the sink)
792
+ "elk.layered.edgeLabels.centerLabelPlacementStrategy": "MEDIAN_LAYER",
793
+ },
794
+ children,
795
+ edges: [
796
+ // Ports live on leaves. An endpoint that is an expanded frame (or a zone)
797
+ // has none, so attach to the compound itself — otherwise ELK is handed a
798
+ // port id that does not exist and throws a raw JsonImportException,
799
+ // which is exactly the un-actionable failure CLAUDE.md forbids.
800
+ ...elkEdges.map((e) => ({
801
+ id: e.id,
802
+ sources: [frameLabels.has(e.from) || zoneById.has(e.from) ? e.from : `${e.id}.src`],
803
+ targets: [frameLabels.has(e.to) || zoneById.has(e.to) ? e.to : `${e.id}.dst`],
804
+ // Label space is reserved by the layout, not scavenged after it — but
805
+ // an inline label dummy costs a whole extra layer at full spacing
806
+ // (spiked: 56 → 130 for one 18px label), which read as giant gaps at
807
+ // review. So the spacing is inverted: between-layers drops to 16 and
808
+ // EVERY edge carries a label — real ones sized for their pill,
809
+ // unlabelled ones an invisible spacer — so labelled and unlabelled
810
+ // gaps come out at exactly the density spacing, and the pill lands
811
+ // centred on its own wire, which is where pills always sat. The label
812
+ // no longer costs anything; it just cannot be collided with.
813
+ ...(hasElkLabels
814
+ ? { labels: [labelFor(e), ...edgeNotes.filter((n) => n.edgeId === e.id)
815
+ .map((n) => ({ text: `note:${n.i}`, width: n.w, height: n.h }))] }
816
+ : {}),
817
+ })),
818
+ ...scaffold.map((s) => ({ id: s.id, sources: [s.from], targets: [s.to], ...(hasElkLabels ? { labels: [{ text: " ", width: 2, height: spacerH(false) }] } : {}) })),
819
+ ...layerNotes.map((n) => ({
820
+ id: `noteedge.${n.i}`,
821
+ sources: [n.above ? n.id : n.anchor],
822
+ targets: [n.above ? n.anchor : n.id],
823
+ ...(hasElkLabels ? { labels: [{ text: " ", width: 2, height: spacerH(false) }] } : {}),
824
+ })),
825
+ ],
826
+ };
827
+ const out = await new ELK().layout(elkGraph);
828
+ const q = Math.round;
829
+ // recursive extraction: compound (zone/frame) children carry parent-relative
830
+ // coords
831
+ const nodes = [];
832
+ const frames = [];
833
+ const pZones = [];
834
+ const ports = [];
835
+ // ELK reports each edge in the coordinate system of its `container` node —
836
+ // for edges living fully inside an expanded frame, that's the frame, so we
837
+ // need every compound's absolute origin to translate them.
838
+ const containerOffset = new Map([["root", { x: 0, y: 0 }]]);
839
+ const walk = (c, ox, oy, depth = 0) => {
840
+ const x = q(ox + c.x), y = q(oy + c.y);
841
+ if (zoneById.has(c.id)) {
842
+ const z = zoneById.get(c.id);
843
+ pZones.push({
844
+ id: z.id, label: z.label, kind: z.kind, icon: z.icon, labelPos: z.labelPos,
845
+ color: z.color, detail: z.detail,
846
+ x, y, w: q(c.width), h: q(c.height), depth,
847
+ });
848
+ containerOffset.set(c.id, { x, y });
849
+ for (const child of c.children ?? [])
850
+ walk(child, x, y, depth + 1);
851
+ return;
852
+ }
853
+ if (frameLabels.has(c.id)) {
854
+ // depth counts the frame chain, not the walk's compound nesting — a
855
+ // frame inside a zone is still depth 0, and keeps the recessed fill
856
+ let fd = 0;
857
+ for (let p = frameParent.get(c.id); p; p = frameParent.get(p))
858
+ fd++;
859
+ frames.push({
860
+ path: c.id, label: frameLabels.get(c.id), x, y, w: q(c.width), h: q(c.height), depth: fd,
861
+ color: frameColors.get(c.id),
862
+ });
863
+ containerOffset.set(c.id, { x, y });
864
+ for (const child of c.children ?? [])
865
+ walk(child, x, y, depth + 1);
866
+ return;
867
+ }
868
+ nodes.push({
869
+ ...byPath.get(c.id),
870
+ x, y, w: q(c.width), h: q(c.height),
871
+ // unitOf, not entityOf: rank is keyed by unit, and for a *zoned* leaf the
872
+ // entity is the leaf itself — entityOf handed every zoned node
873
+ // `rank: undefined`, which made the router's blocker test treat all of
874
+ // them as one rank and route coplanar wires straight through zones.
875
+ rank: rank.get(unitOf(c.id)),
876
+ });
877
+ for (const p of c.ports ?? []) {
878
+ if (p.id.startsWith("scaffold."))
879
+ continue;
880
+ ports.push({
881
+ edge: p.id.replace(/\.(src|dst)$/, ""),
882
+ node: c.id,
883
+ side: SIDE_DOWN[p.layoutOptions?.["elk.port.side"] ?? "SOUTH"],
884
+ x: q(x + p.x),
885
+ y: q(y + p.y),
886
+ });
887
+ }
888
+ };
889
+ const noteBoxes = new Map();
890
+ for (const c of out.children) {
891
+ if (c.id?.startsWith("note:")) {
892
+ noteBoxes.set(+c.id.slice(5), { x: q(c.x), y: q(c.y), w: q(c.width), h: q(c.height) });
893
+ continue;
894
+ }
895
+ walk(c, 0, 0);
896
+ }
897
+ const nodeById = new Map(nodes.map((n) => [n.path, n]));
898
+ const elkPositioned = new Map(out.edges
899
+ .filter((e) => !e.id.startsWith("scaffold.") && !e.id.startsWith("noteedge."))
900
+ .map((e) => {
901
+ const s = e.sections[0];
902
+ const off = containerOffset.get(e.container ?? "root") ?? { x: 0, y: 0 };
903
+ const pts = [s.startPoint, ...(s.bendPoints ?? []), s.endPoint].map((p) => ({ x: q(p.x + off.x), y: q(p.y + off.y) }));
904
+ const m = edges.find((me) => me.id === e.id);
905
+ const noteLabels = (e.labels ?? []).filter((l) => String(l.text).startsWith("note:"));
906
+ for (const l of noteLabels)
907
+ noteBoxes.set(+String(l.text).slice(5), { x: q(l.x + off.x), y: q(l.y + off.y), w: q(l.width), h: q(l.height) });
908
+ const lab = (e.labels ?? []).find((l) => !String(l.text).startsWith("note:"));
909
+ const labelRect = lab
910
+ ? { x: q(lab.x + off.x), y: q(lab.y + off.y), w: q(lab.width), h: q(lab.height) }
911
+ : undefined;
912
+ return [e.id, { id: e.id, from: m.from, to: m.to, label: m.label, async: m.async, animate: m.animate, style: m.style, count: m.count, tags: m.tags, color: m.color, heads: m.heads, points: pts, labelRect }];
913
+ }));
914
+ // ── coplanar router (ours): adjacent → straight; blocked → side-band ─────
915
+ // Blocked edges of one rank share the band beside it, but never a lane when
916
+ // their spans overlap: greedy interval packing, declaration order
917
+ // (deterministic), 16px between lanes.
918
+ //
919
+ // Written along the rank rather than along x. A rank is a *row* under
920
+ // `direction down` and a *column* under `direction right`, so the whole
921
+ // router transposes: `along` is the axis nodes are spread on within a rank,
922
+ // `cross` is the one the ranks advance on, and the band sits past the rank's
923
+ // far edge on the cross axis. Under `down` this is exactly the old code —
924
+ // along = x, cross = y, band below — and must stay byte-identical.
925
+ const along = flowsRight ? "y" : "x";
926
+ const cross = flowsRight ? "x" : "y";
927
+ const alongSize = flowsRight ? "h" : "w";
928
+ const crossSize = flowsRight ? "w" : "h";
929
+ /** A point from (along, cross) coordinates, in the axis order SVG wants. */
930
+ const pt = (a, c) => (flowsRight ? { x: c, y: a } : { x: a, y: c });
931
+ /** The side an edge leaves on: past the rank's far edge, on the cross axis. */
932
+ const bandSide = flowsRight ? "east" : "south";
933
+ const lowSide = flowsRight ? "north" : "west";
934
+ const highSide = flowsRight ? "south" : "east";
935
+ // DESIGN §4: edges on one side spread at even offsets, never stacked at a
936
+ // point. ELK spreads the ports it owns, but it never sees a coplanar edge —
937
+ // that route is ours and is added after ELK has finished — so a node with one
938
+ // of each kind gets two ports on the same side at the same coordinate. A cold
939
+ // agent hit it the first time one appeared: `email_handler ~> sns` crosses a
940
+ // rank (ELK's) and `email_handler ~> dlq` stays in it (ours), and both left
941
+ // the south face at the node's centre.
942
+ //
943
+ // Keep the natural centre when it is free and step along the side only when
944
+ // it is not, so every diagram without a collision is byte-identical. Which
945
+ // axis a side runs along is geometry, not direction: north/south run in x,
946
+ // east/west in y, whichever way the diagram flows.
947
+ const SIDE_AXIS = { north: "x", south: "x", east: "y", west: "y" };
948
+ const AXIS_SIZE = { x: "w", y: "h" };
949
+ const freePort = (n, side, want) => {
950
+ const ax = SIDE_AXIS[side];
951
+ const taken = ports.filter((p) => p.node === n.path && p.side === side).map((p) => p[ax]);
952
+ const clear = (c) => taken.every((t) => Math.abs(t - c) >= 16);
953
+ if (clear(want))
954
+ return want;
955
+ // 8 of margin keeps the stub off the node's own rounded corner
956
+ const [lo, hi] = [n[ax] + 8, n[ax] + n[AXIS_SIZE[ax]] - 8];
957
+ for (let step = 16; step <= n[AXIS_SIZE[ax]]; step += 16)
958
+ for (const c of [want + step, want - step])
959
+ if (c >= lo && c <= hi && clear(c))
960
+ return c;
961
+ return want; // a face with nowhere left to go: draw it rather than not
962
+ };
963
+ const frameByPath = new Map(frames.map((f) => [f.path, f]));
964
+ const zoneRectById = new Map(pZones.map((z) => [z.id, { path: z.id, ...z }]));
965
+ // a frame is its own unit, so try the frame rect first — the leaf branch is
966
+ // only for endpoints that are genuinely bare nodes
967
+ const routeRect = (p) => frameByPath.get(unitOf(p)) ?? nodeById.get(p);
968
+ /** Every unit's rect on a rank — the obstacle set for blockedness. Unlike
969
+ * the old leaf-only scan this sees frames and zones too, so a coplanar
970
+ * wire no longer threads straight through a boundary it never noticed. */
971
+ const unitRect = (u) => frameByPath.get(u) ?? zoneRectById.get(u) ?? nodeById.get(u);
972
+ const edgeRank = (e) => rank.get(unitOf(e.from));
973
+ const blockedBy = (e, a, b) => {
974
+ const [l, r] = a[along] <= b[along] ? [a, b] : [b, a];
975
+ return units.some((u) => {
976
+ if (u === unitOf(e.from) || u === unitOf(e.to))
977
+ return false;
978
+ if (rank.get(u) !== edgeRank(e))
979
+ return false;
980
+ const rect = unitRect(u);
981
+ return !!rect && rect[along] > l[along] && rect[along] < r[along];
982
+ });
983
+ };
984
+ const laneOf = new Map();
985
+ const lanesByRank = new Map();
986
+ for (const e of coplanar) {
987
+ const a = routeRect(e.from);
988
+ const b = routeRect(e.to);
989
+ if (!blockedBy(e, a, b))
990
+ continue;
991
+ const span = {
992
+ lo: Math.min(a[along] + a[alongSize] / 2, b[along] + b[alongSize] / 2),
993
+ hi: Math.max(a[along] + a[alongSize] / 2, b[along] + b[alongSize] / 2),
994
+ };
995
+ const lanes = lanesByRank.get(edgeRank(e)) ?? [];
996
+ let li = lanes.findIndex((spans) => spans.every((sp) => span.hi + 16 <= sp.lo || span.lo >= sp.hi + 16));
997
+ if (li === -1) {
998
+ li = lanes.length;
999
+ lanes.push([]);
1000
+ }
1001
+ lanes[li].push(span);
1002
+ lanesByRank.set(edgeRank(e), lanes);
1003
+ laneOf.set(e.id, li);
1004
+ }
1005
+ const coplanarEdges = coplanar.map((e) => {
1006
+ const a = routeRect(e.from);
1007
+ const b = routeRect(e.to);
1008
+ const blocked = blockedBy(e, a, b);
1009
+ const midCross = (n) => n[cross] + Math.round(n[crossSize] / 2);
1010
+ /** The cross-coordinate the wire wants at an endpoint. A framed leaf pulls
1011
+ * the wire to its own height so the wall entry sits beside it — clamped
1012
+ * into the pair's shared cross-overlap band, because frames on one layer
1013
+ * are top-aligned with unequal sizes and an unclamped anchor could miss
1014
+ * the other rect entirely. */
1015
+ const wantCross = (p, own, other) => {
1016
+ const leaf = byPath.has(p) ? nodeById.get(p) : undefined;
1017
+ const raw = leaf ? midCross(leaf) : midCross(own);
1018
+ const lo = Math.max(own[cross], other[cross]) + 12;
1019
+ const hi = Math.min(own[cross] + own[crossSize], other[cross] + other[crossSize]) - 12;
1020
+ if (lo > hi)
1021
+ return NaN; // no shared band — shelf territory
1022
+ return Math.min(Math.max(raw, lo), hi);
1023
+ };
1024
+ const carry = { label: e.label, async: e.async, animate: e.animate, style: e.style, count: e.count, tags: e.tags, color: e.color, heads: e.heads };
1025
+ // The router owns coplanar geometry, so it reserves and reports
1026
+ // label space the same way ELK does for cross-rank edges — labelRect is
1027
+ // where the pill draws, no search. Straight runs got their gutter widened
1028
+ // at graph build (elk.spacing.individual); lanes are below the band where
1029
+ // width is free.
1030
+ const rectOnRun = (alo, ahi, c) => {
1031
+ if (!e.label)
1032
+ return undefined;
1033
+ const bw = badgeW(e.id);
1034
+ // one rect for pill + badge, exactly as the ELK path reserves it
1035
+ const w = pillDims(e.label, font).w + (bw ? bw + BADGE_GAP : 0);
1036
+ const mid = Math.round((alo + ahi) / 2);
1037
+ const r = pt(mid - Math.round(w / 2), c - 9);
1038
+ return { x: r.x, y: r.y, ...(flowsRight ? { w: 18, h: w } : { w, h: 18 }) };
1039
+ };
1040
+ const bothBare = unitOf(e.from) === e.from && unitOf(e.to) === e.to;
1041
+ if (!blocked && bothBare) {
1042
+ // the pre-frames straight path, byte-for-byte: same-rank sibling leaves
1043
+ // share a cross-centre, the line is straight *because* both ends sit at
1044
+ // a's centre, and it never consults freePort (see the comment above it —
1045
+ // straight pairs cannot collide, and a port probe here could nudge an
1046
+ // existing diagram into a jog it never had)
1047
+ const c = midCross(a);
1048
+ const first = a[along] <= b[along];
1049
+ const pts = first
1050
+ ? [pt(a[along] + a[alongSize], c), pt(b[along], c)]
1051
+ : [pt(a[along], c), pt(b[along] + b[alongSize], c)];
1052
+ const labelRect = first
1053
+ ? rectOnRun(a[along] + a[alongSize], b[along], c)
1054
+ : rectOnRun(b[along] + b[alongSize], a[along], c);
1055
+ ports.push({ edge: e.id, node: a.path, side: first ? highSide : lowSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: b.path, side: first ? lowSide : highSide, x: pts[1].x, y: pts[1].y });
1056
+ return { id: e.id, from: e.from, to: e.to, ...carry, points: pts, labelRect, coplanar: true };
1057
+ }
1058
+ const aWant = wantCross(e.from, a, b);
1059
+ const bWant = wantCross(e.to, b, a);
1060
+ if (!blocked && !Number.isNaN(aWant) && !Number.isNaN(bWant)) {
1061
+ const first = a[along] <= b[along];
1062
+ const [aSide, bSide] = first ? [highSide, lowSide] : [lowSide, highSide];
1063
+ // freePort spreads parallel wall entries 16 apart; when both entries
1064
+ // stay put and agree, the run is straight — otherwise it jogs at
1065
+ // mid-gutter, which also gives every stub gutter/2 ≥ 24 of clearance
1066
+ const aC = freePort(a, aSide, aWant);
1067
+ const bC = freePort(b, bSide, bWant);
1068
+ const aWall = first ? a[along] + a[alongSize] : a[along];
1069
+ const bWall = first ? b[along] : b[along] + b[alongSize];
1070
+ if (aC === bC) {
1071
+ const pts = [pt(aWall, aC), pt(bWall, aC)];
1072
+ const labelRect = rectOnRun(Math.min(aWall, bWall), Math.max(aWall, bWall), aC);
1073
+ ports.push({ edge: e.id, node: a.path, side: aSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: b.path, side: bSide, x: pts[1].x, y: pts[1].y });
1074
+ return { id: e.id, from: e.from, to: e.to, ...carry, points: pts, labelRect, coplanar: true };
1075
+ }
1076
+ // jog: 4-point Z at mid-gutter — the pill sits on the crossing segment
1077
+ const mid = Math.round((Math.min(aWall, bWall) + Math.max(aWall, bWall)) / 2);
1078
+ const pts = [pt(aWall, aC), pt(mid, aC), pt(mid, bC), pt(bWall, bC)];
1079
+ const labelRect = e.label
1080
+ ? (() => {
1081
+ const bw = badgeW(e.id);
1082
+ const w = pillDims(e.label, font).w + (bw ? bw + BADGE_GAP : 0);
1083
+ const c = Math.round((aC + bC) / 2);
1084
+ const r = pt(mid - Math.round(w / 2), c - 9);
1085
+ return { x: r.x, y: r.y, ...(flowsRight ? { w: 18, h: w } : { w, h: 18 }) };
1086
+ })()
1087
+ : undefined;
1088
+ ports.push({ edge: e.id, node: a.path, side: aSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: b.path, side: bSide, x: pts[3].x, y: pts[3].y });
1089
+ return { id: e.id, from: e.from, to: e.to, ...carry, points: pts, labelRect, coplanar: true };
1090
+ }
1091
+ // shelf: past the rank's far edge on the cross axis, measured over every
1092
+ // unit rect on the rank — a frame's border, not the leaves inside it
1093
+ const bandEdge = Math.max(...units.filter((u) => rank.get(u) === edgeRank(e)).map((u) => unitRect(u))
1094
+ .filter((r) => !!r).map((r) => r[cross] + r[crossSize]));
1095
+ // 28 not 16 when lanes carry labels: a pill is 18 tall, and two labelled
1096
+ // lanes at the old pitch would overlap by 2px before margins
1097
+ const lanePitch = coplanar.some((c) => c.label && laneOf.has(c.id)) ? 28 : 16;
1098
+ const lane = bandEdge + 24 + (laneOf.get(e.id) ?? 0) * lanePitch;
1099
+ // exit at the interior leaf's along-centre where there is one, so the drop
1100
+ // reads as belonging to the thing it serves
1101
+ const wantAlong = (p, own) => {
1102
+ const leaf = byPath.has(p) ? nodeById.get(p) : undefined;
1103
+ const raw = leaf ? leaf[along] + Math.round(leaf[alongSize] / 2) : own[along] + Math.round(own[alongSize] / 2);
1104
+ return Math.min(Math.max(raw, own[along] + 8), own[along] + own[alongSize] - 8);
1105
+ };
1106
+ const aA = freePort(a, bandSide, wantAlong(e.from, a));
1107
+ const bA = freePort(b, bandSide, wantAlong(e.to, b));
1108
+ const pts = [
1109
+ pt(aA, a[cross] + a[crossSize]), pt(aA, lane),
1110
+ pt(bA, lane), pt(bA, b[cross] + b[crossSize]),
1111
+ ];
1112
+ ports.push({ edge: e.id, node: a.path, side: bandSide, x: pts[0].x, y: pts[0].y }, { edge: e.id, node: b.path, side: bandSide, x: pts[3].x, y: pts[3].y });
1113
+ return {
1114
+ id: e.id, from: e.from, to: e.to, ...carry, points: pts,
1115
+ labelRect: rectOnRun(Math.min(aA, bA), Math.max(aA, bA), lane),
1116
+ coplanar: true,
1117
+ };
1118
+ });
1119
+ const coplanarById = new Map(coplanarEdges.map((e) => [e.id, e]));
1120
+ const pEdges = edges.map((e) => elkPositioned.get(e.id) ?? coplanarById.get(e.id));
1121
+ // ── annotation pass: chips and badges are layout citizens ────────────────
1122
+ // Moved verbatim from svg.ts (Positioned consolidation): placement is
1123
+ // geometry, geometry belongs here, and checkLayout can only assert what
1124
+ // Positioned carries. Order is the obstacle registry, as ever: pills
1125
+ // (labelRects) exist, then chips avoid them, then badges avoid both, and the
1126
+ // notes resolver downstream sees all three.
1127
+ // Carve each badge out of the left (or top) of the rect reserved for it, and
1128
+ // hand the remainder back as the pill's own rect — so everything downstream,
1129
+ // including the renderer, sees a labelRect that means "the pill draws here"
1130
+ // and nothing has to know a badge was ever involved.
1131
+ /** Nearest point ON the wire, projected onto its segments — not the nearest
1132
+ * vertex, which is always a corner and puts the badge in the elbow. */
1133
+ const nearestPoint = (pts, x, y) => {
1134
+ let best = pts[0], bestD = Infinity;
1135
+ for (let i = 0; i < pts.length - 1; i++) {
1136
+ const a = pts[i], b = pts[i + 1];
1137
+ const dx = b.x - a.x, dy = b.y - a.y;
1138
+ const len2 = dx * dx + dy * dy;
1139
+ const t = len2 ? Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / len2)) : 0;
1140
+ const p = { x: Math.round(a.x + dx * t), y: Math.round(a.y + dy * t) };
1141
+ const d = Math.hypot(p.x - x, p.y - y);
1142
+ if (d < bestD) {
1143
+ bestD = d;
1144
+ best = p;
1145
+ }
1146
+ }
1147
+ return best;
1148
+ };
1149
+ /** Halfway along the polyline, not the middle vertex — a two-point coplanar
1150
+ * run has no middle vertex, and its "middle" would be the arrowhead. */
1151
+ const midOfRun = (pts) => {
1152
+ const segs = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i].x, p.y - pts[i].y));
1153
+ let left = segs.reduce((a, b) => a + b, 0) / 2;
1154
+ for (let i = 0; i < segs.length; i++) {
1155
+ if (segs[i] >= left) {
1156
+ const t = segs[i] ? left / segs[i] : 0;
1157
+ return { x: Math.round(pts[i].x + (pts[i + 1].x - pts[i].x) * t), y: Math.round(pts[i].y + (pts[i + 1].y - pts[i].y) * t) };
1158
+ }
1159
+ left -= segs[i];
1160
+ }
1161
+ return pts[pts.length - 1];
1162
+ };
1163
+ const badges = [];
1164
+ if (graph.flow) {
1165
+ for (const e of pEdges) {
1166
+ const nums = graph.flow.byEdge[e.id] ?? [];
1167
+ if (!nums.length)
1168
+ continue;
1169
+ const bw = badgeW(e.id);
1170
+ const r = e.labelRect;
1171
+ // No pill: the badge is a bead on the wire, centred on the point of the
1172
+ // edge nearest its own label dummy — which ELK placed at the median layer
1173
+ // of *this* edge, so the number can no longer end up beside a neighbour's
1174
+ // line. Falls back to the middle vertex for an edge ELK gave no label.
1175
+ if (!r || !e.label) {
1176
+ const at = r ? nearestPoint(e.points, r.x + r.w / 2, r.y + r.h / 2) : midOfRun(e.points);
1177
+ badges.push({ edgeId: e.id, x: Math.round(at.x - bw / 2), y: at.y - 9, w: bw, h: 18, nums });
1178
+ continue;
1179
+ }
1180
+ // With a pill, the reservation covers both and either end of it is free.
1181
+ // Put the badge at the end nearest the wire: the number then reads as
1182
+ // belonging to that line rather than to whatever pill it sits beside.
1183
+ const vertical = r.h > r.w;
1184
+ const near = nearestPoint(e.points, r.x + r.w / 2, r.y + r.h / 2);
1185
+ if (vertical) {
1186
+ const atTop = near.y <= r.y + r.h / 2;
1187
+ badges.push({ edgeId: e.id, x: r.x + Math.round((r.w - 18) / 2), y: atTop ? r.y : r.y + r.h - bw, w: 18, h: bw, nums });
1188
+ if (atTop)
1189
+ r.y += bw + BADGE_GAP;
1190
+ r.h -= bw + BADGE_GAP;
1191
+ }
1192
+ else {
1193
+ const atLeft = near.x <= r.x + r.w / 2;
1194
+ badges.push({ edgeId: e.id, x: atLeft ? r.x : r.x + r.w - bw, y: r.y + Math.round((r.h - 18) / 2), w: bw, h: 18, nums });
1195
+ if (atLeft)
1196
+ r.x += bw + BADGE_GAP;
1197
+ r.w -= bw + BADGE_GAP;
1198
+ }
1199
+ }
1200
+ }
1201
+ // the DRAWN pill, not the reservation: a reservation can be taller (it
1202
+ // doubles as the gap spacer), and chips historically avoided the 18-tall
1203
+ // pill the renderer draws centred inside it — feeding them the raw
1204
+ // reservation shifted 7 views by 3px at the gate
1205
+ // e.label too, not just labelRect: unlabelled edges carry 2px spacer labels
1206
+ // (the gap-arithmetic trick), and those come back as ghost labelRects. The
1207
+ // renderer always ignored them (`if (!e.label)`); obstacles must too.
1208
+ const pillRects = pEdges.filter((e) => e.label && e.labelRect).map((e) => ({
1209
+ x: e.labelRect.x,
1210
+ y: e.labelRect.y + Math.round((e.labelRect.h - 18) / 2),
1211
+ w: e.labelRect.w, h: 18, edgeId: e.id,
1212
+ }));
1213
+ const hitR = (a, b, m = 4) => a.x < b.x + b.w + m && a.x + a.w + m > b.x && a.y < b.y + b.h + m && a.y + a.h + m > b.y;
1214
+ const chips = [];
1215
+ {
1216
+ const segs = pEdges.flatMap((e) => {
1217
+ const out = [];
1218
+ for (let i = 0; i < e.points.length - 1; i++) {
1219
+ const a = e.points[i], b = e.points[i + 1];
1220
+ out.push({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) });
1221
+ }
1222
+ return out;
1223
+ });
1224
+ const obstacles = () => [...segs, ...nodes, ...pillRects, ...badges, ...chips];
1225
+ for (const z of [...pZones].sort((a, b) => a.depth - b.depth)) {
1226
+ // Chip anatomy (docs/design): a flush icon tab, the label segment, and
1227
+ // an optional mono segment for the boundary's hard fact. Each segment is
1228
+ // measured in the face it will be drawn with — the mono one in mono
1229
+ // metrics — because the placer's rect must be the rect the renderer
1230
+ // fills, or the chip overhangs its own border.
1231
+ const iconW = z.icon ? CHIP_H : 0; // a square tab, flush with the label bed
1232
+ const room = z.w - 24; // 12 clear of each corner
1233
+ const labelNat = Math.round(measure(z.label, fx(11), "500", font.metrics)) + 16;
1234
+ const detailNat = z.detail ? Math.round(measure(z.detail, fx(11), "400", "mono")) + 16 : 0;
1235
+ // The detail segment is all-or-nothing: it holds facts like `10.0.0.0/16`
1236
+ // where a truncated value is not a shortened label, it is a *different
1237
+ // network*. So when the boundary is too narrow for both, the segment goes
1238
+ // and the name keeps its room; only if the name alone still overflows
1239
+ // does it ellipsize.
1240
+ const showDetail = !!z.detail && iconW + labelNat + detailNat <= room;
1241
+ const detail = showDetail ? z.detail : undefined;
1242
+ const detailW = showDetail ? detailNat : 0;
1243
+ const label = fit(z.label, Math.max(24, room - iconW - detailW - 16), fx(11), "500", font.metrics);
1244
+ const w = Math.round(measure(label, fx(11), "500", font.metrics)) + 16 + iconW + detailW;
1245
+ const y = z.labelPos.startsWith("top") ? z.y - CHIP_H / 2 : z.y + z.h - CHIP_H / 2;
1246
+ const xLo = z.x + 12;
1247
+ const xHi = Math.max(xLo, z.x + z.w - 12 - w);
1248
+ const fromRight = z.labelPos.endsWith("right");
1249
+ let best = { x: fromRight ? xHi : xLo, hits: Infinity };
1250
+ for (let step = 0;; step++) {
1251
+ const x = fromRight ? xHi - step * 16 : xLo + step * 16;
1252
+ if (x < xLo || x > xHi)
1253
+ break;
1254
+ const rect = { x, y, w, h: CHIP_H };
1255
+ const n = obstacles().filter((o) => o.x < rect.x + rect.w + 6 && o.x + o.w + 6 > rect.x &&
1256
+ o.y < rect.y + rect.h + 6 && o.y + o.h + 6 > rect.y).length;
1257
+ if (n < best.hits)
1258
+ best = { x, hits: n };
1259
+ if (n === 0)
1260
+ break;
1261
+ }
1262
+ chips.push({
1263
+ x: best.x, y, w, h: CHIP_H, label, zone: z.id, icon: z.icon,
1264
+ ...(detail ? { detail, detailW } : {}),
1265
+ });
1266
+ }
1267
+ }
1268
+ // ── channels (SPEC §6 Tier 2): one trunk instead of N crossing lines ─────
1269
+ // Several edges into the same target drop to a shared horizontal trunk,
1270
+ // run along it, and enter the target as one line. Ours to compute: ELK
1271
+ // routes each edge independently and has no notion of a bus.
1272
+ for (const ch of view.layout.channels) {
1273
+ const target = nodes.find((n) => n.path === ch.target);
1274
+ const members = ch.sources
1275
+ .map((src) => ({
1276
+ src,
1277
+ node: nodes.find((n) => n.path === src),
1278
+ edge: pEdges.find((e) => e.from === src && e.to === ch.target),
1279
+ }))
1280
+ .filter((m) => m.node && m.edge);
1281
+ if (!target || members.length < 2) {
1282
+ diagnostics.push({
1283
+ severity: "warning",
1284
+ message: `channel into \`${ch.target}\`: ${!target ? "the target is not visible here" : "fewer than two of its edges are"}`,
1285
+ fix: "channels only apply where every member edge is visible in the view",
1286
+ loc: ch.loc,
1287
+ });
1288
+ continue;
1289
+ }
1290
+ // the trunk sits between the sources' lowest edge and the target
1291
+ const lowest = Math.max(...members.map((m) => m.node.y + m.node.h));
1292
+ const trunkY = Math.round((lowest + target.y) / 2);
1293
+ if (trunkY <= lowest || trunkY >= target.y) {
1294
+ diagnostics.push({
1295
+ severity: "warning",
1296
+ message: `channel into \`${ch.target}\` has no room for a trunk`,
1297
+ fix: "the sources must sit above the target — check `rows`",
1298
+ loc: ch.loc,
1299
+ });
1300
+ continue;
1301
+ }
1302
+ const entryX = target.x + Math.round(target.w / 2);
1303
+ for (const m of members) {
1304
+ const sx = m.node.x + Math.round(m.node.w / 2);
1305
+ m.edge.points = [
1306
+ { x: sx, y: m.node.y + m.node.h },
1307
+ { x: sx, y: trunkY },
1308
+ { x: entryX, y: trunkY },
1309
+ { x: entryX, y: target.y },
1310
+ ];
1311
+ // ports move with the geometry, so labels and badges follow the wire
1312
+ for (const port of ports)
1313
+ if (port.edge === m.edge.id)
1314
+ Object.assign(port, port.node === ch.target
1315
+ ? { x: entryX, y: target.y, side: "north" }
1316
+ : { x: sx, y: m.node.y + m.node.h, side: "south" });
1317
+ }
1318
+ }
1319
+ // ── align (SPEC §6): an exact shared axis ─────────────────────────────────
1320
+ // ELK gets within ~7px and no further (spiked: scaffold edges,
1321
+ // favorStraightEdges and straightness priority all plateau there), and
1322
+ // "almost aligned" is precisely what DESIGN §1.4 forbids — so the final
1323
+ // snap is ours, same boundary as the coplanar router. The first-listed
1324
+ // element is the anchor; the rest move onto its axis.
1325
+ // a column is an align group: its members share one exact vertical axis
1326
+ // `cols` is implemented as an align group, but the author wrote `cols` — the
1327
+ // warnings below have to say the word that is actually in their file.
1328
+ const alignGroups = [
1329
+ ...view.layout.align.map((g) => ({ ...g, from: "align" })),
1330
+ ...(view.layout.cols ?? [])
1331
+ .filter((c) => c.length > 1)
1332
+ .map((nodes) => ({ nodes, loc: view.loc, from: "cols" })),
1333
+ ];
1334
+ if (alignGroups.length) {
1335
+ const axis = view.layout.direction === "right" ? "y" : "x";
1336
+ const cross = axis === "x" ? "y" : "x";
1337
+ const span = axis === "x" ? "w" : "h";
1338
+ const nodeByPath = new Map(nodes.map((n) => [n.path, n]));
1339
+ const centre = (n) => n[axis] + Math.round(n[span] / 2);
1340
+ /** Shift the run of points anchored at one end of an edge. */
1341
+ const shiftEnd = (e, atStart, d) => {
1342
+ const pts = e.points;
1343
+ const endVal = atStart ? pts[0][axis] : pts[pts.length - 1][axis];
1344
+ let run = 0;
1345
+ while (run < pts.length &&
1346
+ (atStart ? pts[run] : pts[pts.length - 1 - run])[axis] === endVal)
1347
+ run++;
1348
+ if (run >= pts.length) {
1349
+ // a dead-straight edge: it must gain a jog, or it would go diagonal
1350
+ const a = atStart ? pts[0] : pts[pts.length - 1];
1351
+ const b = atStart ? pts[pts.length - 1] : pts[0];
1352
+ const mid = Math.round((a[cross] + b[cross]) / 2);
1353
+ const mk = (av, cv) => (axis === "x" ? { x: av, y: cv } : { x: cv, y: av });
1354
+ const moved = [
1355
+ mk(a[axis] + d, a[cross]), mk(a[axis] + d, mid),
1356
+ mk(b[axis], mid), mk(b[axis], b[cross]),
1357
+ ];
1358
+ e.points = atStart ? moved : moved.reverse();
1359
+ return;
1360
+ }
1361
+ for (let i = 0; i < run; i++) {
1362
+ const pt = atStart ? pts[i] : pts[pts.length - 1 - i];
1363
+ pt[axis] += d;
1364
+ }
1365
+ };
1366
+ for (const group of alignGroups) {
1367
+ const anchor = nodeByPath.get(group.nodes[0]);
1368
+ if (!anchor)
1369
+ continue;
1370
+ const target = centre(anchor);
1371
+ // Round 13 finding. A cold agent asked for six collectors side by side in
1372
+ // the first column of a left-to-right flow and wrote `cols [c1 … c6]` —
1373
+ // reasonable, since on screen that *is* a column. But those six are
1374
+ // siblings on one rank, so no two of them can share an axis, and the
1375
+ // result was six near-identical warnings, none of which named the fix.
1376
+ // One mistake, one diagnostic, and it says which construct to reach for.
1377
+ const members = group.nodes.map((q) => nodeByPath.get(q)).filter(Boolean);
1378
+ if (members.length === group.nodes.length && members.length > 1
1379
+ && members.every((m) => m.rank === members[0].rank)) {
1380
+ const list = group.nodes.join(" ");
1381
+ diagnostics.push({
1382
+ severity: "warning",
1383
+ message: `${group.from} \`[${list}]\` — all ${members.length} sit on the same rank, so they cannot share an axis`,
1384
+ fix: `to put them side by side write \`rows [${list}]\`; \`${group.from}\` stacks nodes *across* ranks`,
1385
+ loc: group.loc,
1386
+ });
1387
+ continue;
1388
+ }
1389
+ for (const path of group.nodes.slice(1)) {
1390
+ const n = nodeByPath.get(path);
1391
+ if (!n)
1392
+ continue;
1393
+ const d = target - centre(n);
1394
+ if (d === 0)
1395
+ continue;
1396
+ if (byPath.get(path)?.frame) {
1397
+ diagnostics.push({
1398
+ severity: "warning",
1399
+ message: `${group.from} skipped \`${path}\` — it sits inside an expanded container`,
1400
+ fix: `align the container itself, or drop the expand in this view`,
1401
+ loc: group.loc,
1402
+ });
1403
+ continue;
1404
+ }
1405
+ // never create an overlap to satisfy a hint
1406
+ const moved = { ...n, [axis]: n[axis] + d };
1407
+ // Zone frames are sized by ELK, long before this pass moves anything,
1408
+ // so an unchecked snap can leave a member drawn outside the boundary
1409
+ // that is supposed to contain it — the diagram then asserts something
1410
+ // false, silently, with check exiting 0. Purely geometric: whatever
1411
+ // encloses the node now must still enclose it after.
1412
+ const encloses = (z, m) => m.x >= z.x && m.y >= z.y && m.x + m.w <= z.x + z.w && m.y + m.h <= z.y + z.h;
1413
+ const escaped = pZones.find((z) => encloses(z, n) && !encloses(z, moved));
1414
+ if (escaped) {
1415
+ diagnostics.push({
1416
+ severity: "warning",
1417
+ message: `${group.from} skipped \`${path}\` — moving it onto \`${group.nodes[0]}\`'s axis would take it outside zone \`${escaped.id}\``,
1418
+ fix: `align \`${path}\` with something inside \`${escaped.id}\`, or drop it from the zone`,
1419
+ loc: group.loc,
1420
+ });
1421
+ continue;
1422
+ }
1423
+ // Every node, not just same-rank ones. The rank filter assumed an
1424
+ // align move stays inside one band, but `place` chains under
1425
+ // `direction right` can put visually adjacent nodes on different
1426
+ // ranks — round 20 wrote `place idx right-of wh` + `align wh idx`,
1427
+ // the guard skipped the cross-rank pair, and the snap parked `idx`
1428
+ // exactly on top of `wh` with check exiting 0. The corpus invariant
1429
+ // sweep caught the overlap; a geometric check has no business
1430
+ // trusting rank labels.
1431
+ const clash = nodes.find((o) => o.path !== path &&
1432
+ moved.x < o.x + o.w + 16 && moved.x + moved.w + 16 > o.x &&
1433
+ moved.y < o.y + o.h + 16 && moved.y + moved.h + 16 > o.y);
1434
+ if (clash) {
1435
+ diagnostics.push({
1436
+ severity: "warning",
1437
+ message: `${group.from} skipped \`${path}\` — moving it onto \`${group.nodes[0]}\`'s axis would collide with \`${clash.path}\``,
1438
+ fix: `reorder that row, or align \`${clash.path}\` too`,
1439
+ loc: group.loc,
1440
+ });
1441
+ continue;
1442
+ }
1443
+ n[axis] += d;
1444
+ for (const pt of ports)
1445
+ if (pt.node === path)
1446
+ pt[axis] += d;
1447
+ for (const e of pEdges) {
1448
+ if (e.from === path)
1449
+ shiftEnd(e, true, d);
1450
+ if (e.to === path)
1451
+ shiftEnd(e, false, d);
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+ // Stacked sheets bleed SHEET_BLEED past a container's right and bottom
1457
+ // edges, and they are drawn from the node rect rather than sized into it:
1458
+ // inflating the node would put ELK's ports on the inflated face, so every
1459
+ // edge would stop 8px short of the card it points at. The bleed lands in
1460
+ // gaps that are wider than it everywhere by construction (node spacing 32,
1461
+ // frame padding 16, zone padding 20, root padding 32) — the one place it
1462
+ // has no gap to land in is the canvas edge, which is what these two lines
1463
+ // fix. `checkLayout` asserts the rest of that claim over the corpus.
1464
+ const bleed = (n) => (n.kind === "card" || n.kind === "context-card" ? SHEET_BLEED : 0);
1465
+ const height = Math.max(q(out.height), ...pEdges.flatMap((e) => e.points.map((p) => p.y + 32)), ...nodes.map((n) => n.y + n.h + bleed(n) + 32));
1466
+ // edge points count toward width exactly as they do toward height — under
1467
+ // `direction right` the router's lanes run along the east side, and a lane
1468
+ // the width never saw fell off the canvas
1469
+ const width = Math.max(q(out.width), ...pEdges.flatMap((e) => e.points.map((p) => p.x + 32)), ...nodes.map((n) => n.x + n.w + bleed(n) + 32));
1470
+ // footer band + pill-extended height, exactly as the renderer computed them
1471
+ // when it owned note placement
1472
+ const drawnBottoms = pillRects.map((r) => r.y + r.h + 16);
1473
+ const heightForNotes = Math.max(height, ...drawnBottoms);
1474
+ const footer = [];
1475
+ if (view.legend || (view.titleblock && Object.keys(view.titleblock).length)) {
1476
+ const rows = Object.entries(view.titleblock ?? {});
1477
+ const tbH = rows.length || view.title
1478
+ ? 12 + (view.title ? 20 : 0) + rows.length * 15 + 6
1479
+ : 0;
1480
+ const bandH = Math.max(view.legend ? 24 : 0, tbH) + 16;
1481
+ footer.push({ x: 0, y: heightForNotes - bandH, w: width, h: bandH });
1482
+ }
1483
+ // ── the note resolver, moved here whole (Positioned consolidation) ───────
1484
+ // Same candidate ladders, same authored-side rule, same travel fallback —
1485
+ // docs/notes/note-placement.md is unchanged as the policy record. The
1486
+ // renderer draws these rects and computes its canvas pad from them exactly
1487
+ // as it did when it owned the placement, so the move is byte-identical.
1488
+ const pNotes = [];
1489
+ {
1490
+ const out = pNotes;
1491
+ const list = view.notes ?? [];
1492
+ const p = { width, height: heightForNotes, nodes, edges: pEdges, noteBoxes };
1493
+ const obstacles = [
1494
+ ...nodes, ...frames, ...pZones, ...pillRects, ...chips, ...badges, ...footer,
1495
+ ];
1496
+ const byPath = new Map(nodes.map((n) => [n.path, n]));
1497
+ // Notes are placed in declaration order and each joins the obstacle set, so
1498
+ // an earlier note wins a contested spot — the same tie-break pills use
1499
+ // (docs/notes/edge-labels.md), and what makes the output deterministic.
1500
+ const placed = [];
1501
+ const clear = (r) => !obstacles.some((o) => hitR(o, r)) && !placed.some((q) => hitR(q, r));
1502
+ let noteIdx = -1;
1503
+ for (const note of list) {
1504
+ noteIdx++;
1505
+ // A note the layout reserved space for draws exactly there — the same
1506
+ // contract as labelRect. The candidate ladder never runs for it.
1507
+ const reserved = p.noteBoxes?.get(noteIdx);
1508
+ // `noteBox`, not a second copy of its arithmetic — see the comment there.
1509
+ // Integers, like every other rect (DESIGN §8): `measure` returns a float
1510
+ // and only x/y used to be rounded, so note widths once shipped as
1511
+ // `width="174.22411799999998"`.
1512
+ const { lines, w, h } = noteBox(note.text, font);
1513
+ // Candidates, in preference order. The authored side is never changed: an
1514
+ // author who wrote `right-of` gets right-of, further out or slid along it,
1515
+ // never flipped to the other side of the node.
1516
+ const cands = [];
1517
+ let anchor;
1518
+ let mid;
1519
+ if (note.anchor.kind === "relpos") {
1520
+ const n = byPath.get(note.anchor.target);
1521
+ if (!n)
1522
+ continue;
1523
+ anchor = n;
1524
+ const rp = note.anchor.relpos;
1525
+ const cx = n.x + Math.round(n.w / 2) - Math.round(w / 2);
1526
+ const cy = n.y + Math.round(n.h / 2) - Math.round(h / 2);
1527
+ // Slide far enough to clear a whole card before standing further off.
1528
+ // ±48 was not enough: a system card is 88 tall, so a note beside a node
1529
+ // with a card to its right exhausted every slide, fell through to the
1530
+ // travel fallback, and ended up across the diagram trailing a leader
1531
+ // through the very card it was avoiding. Nearest-first, so the shortest
1532
+ // leader that works wins.
1533
+ const slides = [0];
1534
+ // Reach past a whole neighbouring card, not just a node: at ±128 a note
1535
+ // beside a crowded column exhausted the ladder, fell through to the
1536
+ // travel fallback and set off sideways across the diagram, trailing a
1537
+ // leader over everything between. Sliding stays on the authored side and
1538
+ // keeps the leader short, so it is always the better escape.
1539
+ for (let d = 16; d <= 240; d += 16)
1540
+ slides.push(-d, d);
1541
+ for (const gap of [24, 48, 72, 96])
1542
+ for (const slide of slides) {
1543
+ if (rp === "right-of")
1544
+ cands.push({ x: n.x + n.w + gap, y: cy + slide });
1545
+ if (rp === "left-of")
1546
+ cands.push({ x: n.x - gap - w, y: cy + slide });
1547
+ if (rp === "above")
1548
+ cands.push({ x: cx + slide, y: n.y - gap - h });
1549
+ if (rp === "below")
1550
+ cands.push({ x: cx + slide, y: n.y + n.h + gap });
1551
+ }
1552
+ }
1553
+ else if (note.anchor.kind === "edge") {
1554
+ const e = p.edges.find((e) => e.from === note.anchor.from && e.to === note.anchor.to);
1555
+ if (!e)
1556
+ continue;
1557
+ // Halfway along the run, by arc length. This used to index
1558
+ // `points[floor(n/2) - 1]`, which for the common two-point edge is
1559
+ // `points[0]` — the *start* — so the note was pinned to its source node
1560
+ // and drawn over it every time.
1561
+ const at = (frac) => {
1562
+ const pts = e.points;
1563
+ const segs = pts.slice(1).map((q2, i2) => Math.hypot(q2.x - pts[i2].x, q2.y - pts[i2].y));
1564
+ const want = segs.reduce((a2, b2) => a2 + b2, 0) * frac;
1565
+ let run = 0;
1566
+ for (let i = 0; i < segs.length; i++) {
1567
+ if (run + segs[i] >= want) {
1568
+ const f = segs[i] === 0 ? 0 : (want - run) / segs[i];
1569
+ return {
1570
+ x: pts[i].x + (pts[i + 1].x - pts[i].x) * f,
1571
+ y: pts[i].y + (pts[i + 1].y - pts[i].y) * f,
1572
+ };
1573
+ }
1574
+ run += segs[i];
1575
+ }
1576
+ return pts[pts.length - 1];
1577
+ };
1578
+ mid = at(0.5);
1579
+ // slide along the wire before standing further off it — same ladder the
1580
+ // edge-label pills walk, so a note and its pill spread rather than stack
1581
+ for (const off of [16, 40, 64])
1582
+ for (const frac of [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74]) {
1583
+ const q = at(frac);
1584
+ cands.push({ x: q.x + off, y: q.y - Math.round(h / 2) });
1585
+ }
1586
+ }
1587
+ else {
1588
+ // A corner note is chrome: it means "pinned to the frame", so it hugs an
1589
+ // edge rather than drifting toward the middle. Stepping inward
1590
+ // diagonally was the first attempt and it read as a floating note that
1591
+ // had lost its corner. Slide along the horizontal edge first, then the
1592
+ // vertical one, staying at the 16px inset the whole way.
1593
+ const c = note.anchor.corner;
1594
+ const x0 = c.includes("left") ? 16 : p.width - w - 16;
1595
+ const y0 = c.includes("top") ? 16 : p.height - h - 16;
1596
+ const dx = c.includes("left") ? 24 : -24;
1597
+ // A short slide along the edge for a near-miss…
1598
+ for (let k = 0; k <= 4; k++)
1599
+ cands.push({ x: x0 + k * dx, y: y0 });
1600
+ // …then outward, off the current canvas. Growing the diagram is the
1601
+ // right answer for chrome: the note keeps its corner and the content
1602
+ // makes room, rather than the note wandering along the bottom edge until
1603
+ // `bottom-right` ends up on the left, which is what sliding produced.
1604
+ const out = c.includes("top") ? -24 : 24;
1605
+ for (let k = 1; k <= 12; k++)
1606
+ cands.push({ x: x0, y: y0 + k * out });
1607
+ }
1608
+ // Two passes: prefer a spot inside the canvas we already have, and only
1609
+ // spill outside — which widens or lengthens the whole diagram — when there
1610
+ // is genuinely nowhere in. Without this the ladder took the first clear
1611
+ // candidate in order, and since it tries the near side first, a `below`
1612
+ // note dodging a label pill slid left off the canvas and pushed every
1613
+ // other element 200px right to make room for it.
1614
+ const inX = (r) => r.x >= 0 && r.x + r.w <= p.width;
1615
+ const inY = (r) => r.y >= 0 && r.y + r.h <= p.height;
1616
+ // Three passes, loosening one axis at a time. Growing the canvas is
1617
+ // sometimes unavoidable — a `below` note on the bottom row has nowhere
1618
+ // else to go — but there is a real difference between extending downward,
1619
+ // which reads as the diagram getting taller, and sliding off the left
1620
+ // edge, which shifts every other element sideways to make room. Taking
1621
+ // the first clear candidate in ladder order did the latter.
1622
+ const fits = [
1623
+ (r) => inX(r) && inY(r),
1624
+ (r) => inX(r),
1625
+ () => true,
1626
+ ];
1627
+ let x = Math.round(cands[0]?.x ?? 16), y = Math.round(cands[0]?.y ?? 16);
1628
+ let found = false;
1629
+ if (reserved) {
1630
+ x = reserved.x;
1631
+ y = reserved.y;
1632
+ found = true;
1633
+ }
1634
+ for (const fit of fits) {
1635
+ for (const c of cands) {
1636
+ const r = { x: Math.round(c.x), y: Math.round(c.y), w, h };
1637
+ if (!fit(r) || !clear(r))
1638
+ continue;
1639
+ x = r.x;
1640
+ y = r.y;
1641
+ found = true;
1642
+ break;
1643
+ }
1644
+ if (found)
1645
+ break;
1646
+ }
1647
+ // Nothing on the ladder was free. Keep travelling in the authored
1648
+ // direction until it is: a note may leave the cluster entirely because its
1649
+ // dotted leader keeps it attached — the property edge-labels.md wishes
1650
+ // pills had. Bounded, like the pill fallback, so a pathological diagram
1651
+ // cannot spin.
1652
+ if (!found) {
1653
+ const rp = note.anchor.kind === "relpos" ? note.anchor.relpos : undefined;
1654
+ const step = rp === "above" ? { dx: 0, dy: -24 }
1655
+ : rp === "below" ? { dx: 0, dy: 24 }
1656
+ : rp === "left-of" ? { dx: -24, dy: 0 }
1657
+ : note.anchor.kind === "corner"
1658
+ // keep travelling along the edge it is pinned to, never into the middle
1659
+ ? { dx: 0, dy: note.anchor.corner.includes("top") ? 24 : -24 }
1660
+ : { dx: 24, dy: 0 };
1661
+ const last = cands[cands.length - 1] ?? { x, y };
1662
+ x = Math.round(last.x);
1663
+ y = Math.round(last.y);
1664
+ for (let guard = 0; guard < 50 && !clear({ x, y, w, h }); guard++) {
1665
+ x += step.dx;
1666
+ y += step.dy;
1667
+ }
1668
+ }
1669
+ placed.push({ x, y, w, h });
1670
+ // the leader is drawn from where the note *ended up*, not where it was
1671
+ // first tried
1672
+ // Attach at the nearest point on each box, not at a fixed side-midpoint.
1673
+ // The midpoint was right when a note always sat exactly beside its anchor,
1674
+ // but a note now slides to dodge obstacles — so a note that ended up well
1675
+ // below its node still had its leader leaving the node's right edge dead
1676
+ // centre, reading as a diagonal fired out of the right-hand side. Clamping
1677
+ // is also a no-op for the un-slid case (a note level with its node still
1678
+ // attaches at the side midpoint), so nothing that was already correct
1679
+ // moves.
1680
+ const clamp = (pt, r) => ({
1681
+ x: Math.max(r.x, Math.min(pt.x, r.x + r.w)),
1682
+ y: Math.max(r.y, Math.min(pt.y, r.y + r.h)),
1683
+ });
1684
+ const noteRect = { x, y, w, h };
1685
+ const noteMid = { x: x + w / 2, y: y + h / 2 };
1686
+ let leader;
1687
+ if (anchor) {
1688
+ const n = { x: anchor.x, y: anchor.y, w: anchor.w, h: anchor.h };
1689
+ const from = clamp({ x: n.x + n.w / 2, y: n.y + n.h / 2 }, noteRect);
1690
+ const to = clamp(noteMid, n);
1691
+ leader = { x1: from.x, y1: from.y, x2: to.x, y2: to.y };
1692
+ }
1693
+ else if (mid) {
1694
+ const from = clamp(mid, noteRect);
1695
+ leader = { x1: from.x, y1: from.y, x2: mid.x, y2: mid.y };
1696
+ }
1697
+ // canvas pad/shift is the renderer's: it recomputes the extent from these
1698
+ // rects exactly as it did when it owned placement, so the move is inert
1699
+ out.push({
1700
+ i: noteIdx, x, y, w, h, lines,
1701
+ leader: leader
1702
+ ? { x1: Math.round(leader.x1), y1: Math.round(leader.y1), x2: Math.round(leader.x2), y2: Math.round(leader.y2) }
1703
+ : undefined,
1704
+ });
1705
+ }
1706
+ }
1707
+ return {
1708
+ positioned: {
1709
+ name: view.name,
1710
+ width,
1711
+ height, nodes, edges: pEdges, ports, frames,
1712
+ zones: pZones,
1713
+ flow: graph.flow,
1714
+ lines: view.layout.lines ?? "orthogonal",
1715
+ noteBoxes,
1716
+ chips, badges,
1717
+ notes: pNotes,
1718
+ },
1719
+ diagnostics,
1720
+ };
1721
+ }