@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.
- package/LICENSE +202 -0
- package/NOTICE +41 -0
- package/dist/api.d.ts +91 -0
- package/dist/api.js +232 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +9 -0
- package/dist/diff/diff.d.ts +30 -0
- package/dist/diff/diff.js +365 -0
- package/dist/fonts.generated.d.ts +1 -0
- package/dist/fonts.generated.js +6 -0
- package/dist/grammar/parser.js +22 -0
- package/dist/grammar/parser.terms.js +115 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/layout/layout.d.ts +197 -0
- package/dist/layout/layout.js +1721 -0
- package/dist/metrics.d.ts +20 -0
- package/dist/metrics.generated.d.ts +4 -0
- package/dist/metrics.generated.js +4 -0
- package/dist/metrics.js +57 -0
- package/dist/model/build.d.ts +8 -0
- package/dist/model/build.js +1343 -0
- package/dist/model/packs.d.ts +13 -0
- package/dist/model/packs.js +29 -0
- package/dist/model/source.d.ts +10 -0
- package/dist/model/source.js +28 -0
- package/dist/model/suggest.d.ts +2 -0
- package/dist/model/suggest.js +24 -0
- package/dist/model/types.d.ts +226 -0
- package/dist/model/types.js +24 -0
- package/dist/packs/node-fs.d.ts +1 -0
- package/dist/packs/node-fs.js +37 -0
- package/dist/packs/registry.d.ts +61 -0
- package/dist/packs/registry.js +122 -0
- package/dist/packs/sanitize.d.ts +12 -0
- package/dist/packs/sanitize.js +127 -0
- package/dist/packs/sysGlyphs.d.ts +2 -0
- package/dist/packs/sysGlyphs.js +21 -0
- package/dist/render/adaptive.d.ts +13 -0
- package/dist/render/adaptive.js +112 -0
- package/dist/render/html/runtime.d.ts +1 -0
- package/dist/render/html/runtime.generated.d.ts +1 -0
- package/dist/render/html/runtime.generated.js +6 -0
- package/dist/render/html/runtime.js +362 -0
- package/dist/render/html.d.ts +39 -0
- package/dist/render/html.js +235 -0
- package/dist/render/svg.d.ts +75 -0
- package/dist/render/svg.js +1403 -0
- package/dist/render/validate.d.ts +4 -0
- package/dist/render/validate.js +9 -0
- package/dist/themes/index.d.ts +84 -0
- package/dist/themes/index.js +90 -0
- package/dist/view/dive.d.ts +55 -0
- package/dist/view/dive.js +57 -0
- package/dist/view/navigate.d.ts +38 -0
- package/dist/view/navigate.js +81 -0
- package/dist/view/resolve.d.ts +92 -0
- package/dist/view/resolve.js +591 -0
- package/fonts/inter-400.ttf +0 -0
- package/fonts/inter-500.ttf +0 -0
- package/fonts/inter-600.ttf +0 -0
- package/fonts/mono-400.ttf +0 -0
- package/metrics.json +510 -0
- 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
|
+
}
|