@kekonic/diagrams-layout 1.0.0-rc.4
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 +21 -0
- package/README.md +25 -0
- package/dist/index.d.mts +272 -0
- package/dist/index.mjs +4068 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,4068 @@
|
|
|
1
|
+
import { classifyBranch, findColumnIndex, getKindDefaults, kindHasCapability, kindSubtitle, mergeOptions, sequenceFragmentDisplayName } from "@kekonic/diagrams-core";
|
|
2
|
+
import { attachPointOnPerimeter, geometrySizeForContent, normalizeShapeId, relativeContentBox, resolveShapeGeometry } from "@kekonic/diagrams-geometry";
|
|
3
|
+
import { iconDisplaySize, resolveIcon } from "@kekonic/diagrams-icons";
|
|
4
|
+
import * as opentypeNs from "opentype.js";
|
|
5
|
+
import ELK from "elkjs/lib/elk.bundled.js";
|
|
6
|
+
//#region src/measure/font-measurer.ts
|
|
7
|
+
const opentypeModule = opentypeNs;
|
|
8
|
+
const opentype = typeof opentypeModule.parse === "function" ? opentypeModule : opentypeModule["default"];
|
|
9
|
+
let cachedFont;
|
|
10
|
+
function loadDefaultFont() {
|
|
11
|
+
if (typeof document !== "undefined") return null;
|
|
12
|
+
if (cachedFont !== void 0) return cachedFont;
|
|
13
|
+
try {
|
|
14
|
+
const runtimeProcess = globalThis.process;
|
|
15
|
+
const moduleBuiltin = runtimeProcess?.getBuiltinModule?.("module");
|
|
16
|
+
const fsBuiltin = runtimeProcess?.getBuiltinModule?.("fs");
|
|
17
|
+
if (!moduleBuiltin || !fsBuiltin) return null;
|
|
18
|
+
const { createRequire } = moduleBuiltin;
|
|
19
|
+
const { readFileSync } = fsBuiltin;
|
|
20
|
+
const buffer = readFileSync(createRequire(import.meta.url).resolve("@fontsource/inter/files/inter-latin-500-normal.woff"));
|
|
21
|
+
cachedFont = opentype.parse(buffer);
|
|
22
|
+
return cachedFont;
|
|
23
|
+
} catch {
|
|
24
|
+
cachedFont = null;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function createFontFileMeasurer() {
|
|
29
|
+
const font = loadDefaultFont();
|
|
30
|
+
if (!font) return null;
|
|
31
|
+
return {
|
|
32
|
+
measureText(text, style) {
|
|
33
|
+
const scale = style.fontSize / font.unitsPerEm;
|
|
34
|
+
let width = 0;
|
|
35
|
+
for (const ch of text) {
|
|
36
|
+
const glyph = font.charToGlyph(ch);
|
|
37
|
+
width += (glyph.advanceWidth ?? 0) * scale;
|
|
38
|
+
}
|
|
39
|
+
const ascent = (font.ascender ?? font.unitsPerEm * .8) * scale;
|
|
40
|
+
const descent = Math.abs(font.descender ?? font.unitsPerEm * .2) * scale;
|
|
41
|
+
return {
|
|
42
|
+
width,
|
|
43
|
+
height: ascent + descent,
|
|
44
|
+
ascent,
|
|
45
|
+
descent
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
wrapText(text, options) {
|
|
49
|
+
const words = text.split(/\s+/);
|
|
50
|
+
const lines = [];
|
|
51
|
+
let current = "";
|
|
52
|
+
let maxW = 0;
|
|
53
|
+
for (const word of words) {
|
|
54
|
+
const test = current ? `${current} ${word}` : word;
|
|
55
|
+
if (this.measureText(test, options.style).width > options.maxWidth && current) {
|
|
56
|
+
lines.push(current);
|
|
57
|
+
maxW = Math.max(maxW, this.measureText(current, options.style).width);
|
|
58
|
+
current = word;
|
|
59
|
+
} else current = test;
|
|
60
|
+
}
|
|
61
|
+
if (current) {
|
|
62
|
+
lines.push(current);
|
|
63
|
+
maxW = Math.max(maxW, this.measureText(current, options.style).width);
|
|
64
|
+
}
|
|
65
|
+
const lineH = options.style.fontSize * LINE_HEIGHT;
|
|
66
|
+
return {
|
|
67
|
+
lines: lines.length ? lines : [text],
|
|
68
|
+
width: maxW,
|
|
69
|
+
height: (lines.length || 1) * lineH
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/measure/text-measurer.ts
|
|
76
|
+
/** Shared font metrics — browser canvas and approximate server fallback. */
|
|
77
|
+
const DEFAULT_FONT_FAMILY = "\"Inter\", \"Segoe UI\", system-ui, sans-serif";
|
|
78
|
+
const LINE_HEIGHT = 1.2;
|
|
79
|
+
const CHAR_WIDTH_RATIO = .55;
|
|
80
|
+
let measurerUsesFallback = false;
|
|
81
|
+
function measurerUsedApproximationFallback() {
|
|
82
|
+
return measurerUsesFallback;
|
|
83
|
+
}
|
|
84
|
+
function createDefaultMeasurer() {
|
|
85
|
+
if (typeof document !== "undefined") return createCanvasMeasurer();
|
|
86
|
+
const fontMeasurer = createFontFileMeasurer();
|
|
87
|
+
if (fontMeasurer) return fontMeasurer;
|
|
88
|
+
measurerUsesFallback = true;
|
|
89
|
+
return createApproximateMeasurer();
|
|
90
|
+
}
|
|
91
|
+
function createApproximateMeasurer() {
|
|
92
|
+
return {
|
|
93
|
+
measureText(text, style) {
|
|
94
|
+
return {
|
|
95
|
+
width: text.length * style.fontSize * CHAR_WIDTH_RATIO,
|
|
96
|
+
height: style.fontSize * LINE_HEIGHT,
|
|
97
|
+
ascent: style.fontSize * .8,
|
|
98
|
+
descent: style.fontSize * .2
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
wrapText(text, options) {
|
|
102
|
+
const words = text.split(/\s+/);
|
|
103
|
+
const lines = [];
|
|
104
|
+
let current = "";
|
|
105
|
+
let maxW = 0;
|
|
106
|
+
for (const word of words) {
|
|
107
|
+
const test = current ? `${current} ${word}` : word;
|
|
108
|
+
if (test.length * options.style.fontSize * CHAR_WIDTH_RATIO > options.maxWidth && current) {
|
|
109
|
+
lines.push(current);
|
|
110
|
+
maxW = Math.max(maxW, current.length * options.style.fontSize * CHAR_WIDTH_RATIO);
|
|
111
|
+
current = word;
|
|
112
|
+
} else current = test;
|
|
113
|
+
}
|
|
114
|
+
if (current) {
|
|
115
|
+
lines.push(current);
|
|
116
|
+
maxW = Math.max(maxW, current.length * options.style.fontSize * CHAR_WIDTH_RATIO);
|
|
117
|
+
}
|
|
118
|
+
const lineH = options.style.fontSize * LINE_HEIGHT;
|
|
119
|
+
return {
|
|
120
|
+
lines: lines.length ? lines : [text],
|
|
121
|
+
width: maxW,
|
|
122
|
+
height: (lines.length || 1) * lineH
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function createCanvasMeasurer(canvas) {
|
|
128
|
+
const ctx = (canvas ?? (typeof document !== "undefined" ? document.createElement("canvas") : null))?.getContext("2d") ?? null;
|
|
129
|
+
return {
|
|
130
|
+
measureText(text, style) {
|
|
131
|
+
if (ctx) {
|
|
132
|
+
ctx.font = `${style.fontWeight ?? "500"} ${style.fontSize}px ${style.fontFamily}`;
|
|
133
|
+
const m = ctx.measureText(text);
|
|
134
|
+
const ascent = m.actualBoundingBoxAscent ?? style.fontSize * .8;
|
|
135
|
+
const descent = m.actualBoundingBoxDescent ?? style.fontSize * .2;
|
|
136
|
+
return {
|
|
137
|
+
width: m.width,
|
|
138
|
+
height: ascent + descent,
|
|
139
|
+
ascent,
|
|
140
|
+
descent
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
width: text.length * style.fontSize * CHAR_WIDTH_RATIO,
|
|
145
|
+
height: style.fontSize * LINE_HEIGHT,
|
|
146
|
+
ascent: style.fontSize * .8,
|
|
147
|
+
descent: style.fontSize * .2
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
wrapText(text, options) {
|
|
151
|
+
const words = text.split(/\s+/);
|
|
152
|
+
const lines = [];
|
|
153
|
+
let current = "";
|
|
154
|
+
let maxW = 0;
|
|
155
|
+
for (const word of words) {
|
|
156
|
+
const test = current ? `${current} ${word}` : word;
|
|
157
|
+
if (this.measureText(test, options.style).width > options.maxWidth && current) {
|
|
158
|
+
lines.push(current);
|
|
159
|
+
maxW = Math.max(maxW, this.measureText(current, options.style).width);
|
|
160
|
+
current = word;
|
|
161
|
+
} else current = test;
|
|
162
|
+
}
|
|
163
|
+
if (current) {
|
|
164
|
+
lines.push(current);
|
|
165
|
+
maxW = Math.max(maxW, this.measureText(current, options.style).width);
|
|
166
|
+
}
|
|
167
|
+
const lineH = options.style.fontSize * LINE_HEIGHT;
|
|
168
|
+
return {
|
|
169
|
+
lines: lines.length ? lines : [text],
|
|
170
|
+
width: maxW,
|
|
171
|
+
height: (lines.length || 1) * lineH
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
let _defaultMeasurer;
|
|
177
|
+
function resetDefaultMeasurer() {
|
|
178
|
+
_defaultMeasurer = void 0;
|
|
179
|
+
measurerUsesFallback = false;
|
|
180
|
+
}
|
|
181
|
+
function lazyDefaultMeasurer() {
|
|
182
|
+
if (!_defaultMeasurer) _defaultMeasurer = createDefaultMeasurer();
|
|
183
|
+
return _defaultMeasurer;
|
|
184
|
+
}
|
|
185
|
+
const defaultMeasurer = {
|
|
186
|
+
measureText(text, style) {
|
|
187
|
+
return lazyDefaultMeasurer().measureText(text, style);
|
|
188
|
+
},
|
|
189
|
+
wrapText(text, options) {
|
|
190
|
+
return lazyDefaultMeasurer().wrapText(text, options);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/measure/table-measure.ts
|
|
195
|
+
/** Dense ERD chrome — flatter and tighter than architecture cards. */
|
|
196
|
+
const TABLE_PAD_X = 10;
|
|
197
|
+
const TABLE_HEADER_H = 28;
|
|
198
|
+
const TABLE_ROW_H = 20;
|
|
199
|
+
/** Badge chip width — must match SVG render. */
|
|
200
|
+
const TABLE_BADGE_W = 18;
|
|
201
|
+
const TABLE_BADGE_GAP = 3;
|
|
202
|
+
/**
|
|
203
|
+
* Fixed key-badge gutter (fits PK+FK, or empty spacer).
|
|
204
|
+
* Names always start at padX + KEY_COL regardless of how many key chips are present.
|
|
205
|
+
*/
|
|
206
|
+
const TABLE_KEY_COL = 43;
|
|
207
|
+
const TABLE_RX = 4;
|
|
208
|
+
/** Gap between type / NN / note in the right-side attrs cluster. */
|
|
209
|
+
const TABLE_ATTR_GAP = 6;
|
|
210
|
+
function isErdTableNode(node) {
|
|
211
|
+
return node.shape === "table" && Boolean(node.columns && node.columns.length > 0);
|
|
212
|
+
}
|
|
213
|
+
function columnAnchorY(tableTop, rowIndex, scale = 1) {
|
|
214
|
+
const headerH = 28 * scale;
|
|
215
|
+
const rowH = 20 * scale;
|
|
216
|
+
return tableTop + headerH + rowIndex * rowH + rowH / 2;
|
|
217
|
+
}
|
|
218
|
+
/** SQL-ish type only — muted mono on the right (flags/notes are separate). */
|
|
219
|
+
function columnTypeLabel(col) {
|
|
220
|
+
return col.type?.trim() ?? "";
|
|
221
|
+
}
|
|
222
|
+
/** Enum / comment secondary text — never joined into the type with bullets. */
|
|
223
|
+
function columnNoteLabel(col) {
|
|
224
|
+
return col.note?.trim() ?? "";
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Size an ERD table card from its title + columns using the shared text measurer.
|
|
228
|
+
*/
|
|
229
|
+
function measureTableNode(node, measurer, scale, effectiveMinW, _effectiveMaxW) {
|
|
230
|
+
const padX = 10 * scale;
|
|
231
|
+
const headerH = 28 * scale;
|
|
232
|
+
const rowH = 20 * scale;
|
|
233
|
+
const keyCol = 43 * scale;
|
|
234
|
+
const typeGap = 10 * scale;
|
|
235
|
+
const attrGap = 6 * scale;
|
|
236
|
+
const titleSize = 12 * scale;
|
|
237
|
+
const colNameSize = 11 * scale;
|
|
238
|
+
const colTypeSize = 10 * scale;
|
|
239
|
+
const colNoteSize = 9.5 * scale;
|
|
240
|
+
const titleMetrics = measurer.measureText(node.label, {
|
|
241
|
+
fontSize: titleSize,
|
|
242
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
243
|
+
fontWeight: "700"
|
|
244
|
+
});
|
|
245
|
+
let maxRowContent = 0;
|
|
246
|
+
const columns = node.columns ?? [];
|
|
247
|
+
for (const col of columns) {
|
|
248
|
+
const nameW = measurer.measureText(col.name, {
|
|
249
|
+
fontSize: colNameSize,
|
|
250
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
251
|
+
fontWeight: "600"
|
|
252
|
+
}).width;
|
|
253
|
+
const typeLabel = columnTypeLabel(col);
|
|
254
|
+
const noteLabel = columnNoteLabel(col);
|
|
255
|
+
const typeW = typeLabel ? measurer.measureText(typeLabel, {
|
|
256
|
+
fontSize: colTypeSize,
|
|
257
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
258
|
+
fontWeight: "500"
|
|
259
|
+
}).width : 0;
|
|
260
|
+
const noteW = noteLabel ? measurer.measureText(noteLabel, {
|
|
261
|
+
fontSize: colNoteSize,
|
|
262
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
263
|
+
fontWeight: "500"
|
|
264
|
+
}).width : 0;
|
|
265
|
+
const parts = [
|
|
266
|
+
typeW,
|
|
267
|
+
col.notNull ? measurer.measureText("NN", {
|
|
268
|
+
fontSize: colTypeSize,
|
|
269
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
270
|
+
fontWeight: "700"
|
|
271
|
+
}).width : 0,
|
|
272
|
+
noteW
|
|
273
|
+
].filter((w) => w > 0);
|
|
274
|
+
const attrsW = parts.reduce((sum, w) => sum + w, 0) + Math.max(0, parts.length - 1) * attrGap;
|
|
275
|
+
maxRowContent = Math.max(maxRowContent, keyCol + nameW + (attrsW ? typeGap + attrsW : 0));
|
|
276
|
+
}
|
|
277
|
+
const contentW = Math.max(titleMetrics.width + 8 * scale, maxRowContent) + padX * 2;
|
|
278
|
+
return {
|
|
279
|
+
width: Math.max(effectiveMinW, 180 * scale, contentW),
|
|
280
|
+
height: headerH + Math.max(1, columns.length) * rowH,
|
|
281
|
+
headerHeight: headerH,
|
|
282
|
+
rowHeight: rowH,
|
|
283
|
+
labelLines: [node.label]
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** Key chips only (PK/FK/UK) — fixed left gutter; NN is not a key. */
|
|
287
|
+
function tableKeyBadges(col) {
|
|
288
|
+
const badges = [];
|
|
289
|
+
if (col.keys.includes("pk")) badges.push("PK");
|
|
290
|
+
if (col.keys.includes("fk")) badges.push("FK");
|
|
291
|
+
if (col.keys.includes("uk")) badges.push("UK");
|
|
292
|
+
return badges;
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/measure/measure.ts
|
|
296
|
+
const PADDING_X = 22;
|
|
297
|
+
const PADDING_Y = 20;
|
|
298
|
+
const RICH_PADDING_Y = 26;
|
|
299
|
+
const ICON_BLOCK = 28;
|
|
300
|
+
const SUBTITLE_HEIGHT = 15;
|
|
301
|
+
const TECHNOLOGY_HEIGHT = 14;
|
|
302
|
+
const DESCRIPTION_LINE_HEIGHT = 14;
|
|
303
|
+
const SECTION_GAP = 4;
|
|
304
|
+
const NOTE_HEIGHT = 14;
|
|
305
|
+
const MIN_ICON_COL = 32;
|
|
306
|
+
const CARD_ICON_HEIGHT = 20;
|
|
307
|
+
/** Matches SVG `CARD_PAD + ICON_TEXT_GAP` (18 + 12). */
|
|
308
|
+
const CARD_ICON_PAD = 30;
|
|
309
|
+
const ICON_ONLY_SIZE = 64;
|
|
310
|
+
const ICON_ONLY_CAPTION = 16;
|
|
311
|
+
const SHAPE_EXTRA = {
|
|
312
|
+
diamond: {
|
|
313
|
+
w: 36,
|
|
314
|
+
h: 36
|
|
315
|
+
},
|
|
316
|
+
cylinder: {
|
|
317
|
+
w: 10,
|
|
318
|
+
h: 22
|
|
319
|
+
},
|
|
320
|
+
hexagon: {
|
|
321
|
+
w: 18,
|
|
322
|
+
h: 10
|
|
323
|
+
},
|
|
324
|
+
person: {
|
|
325
|
+
w: 24,
|
|
326
|
+
h: 0
|
|
327
|
+
},
|
|
328
|
+
queue: {
|
|
329
|
+
w: 18,
|
|
330
|
+
h: 18
|
|
331
|
+
},
|
|
332
|
+
parallelogram: {
|
|
333
|
+
w: 24,
|
|
334
|
+
h: 8
|
|
335
|
+
},
|
|
336
|
+
trapezoid: {
|
|
337
|
+
w: 20,
|
|
338
|
+
h: 8
|
|
339
|
+
},
|
|
340
|
+
triangle: {
|
|
341
|
+
w: 16,
|
|
342
|
+
h: 20
|
|
343
|
+
},
|
|
344
|
+
document: {
|
|
345
|
+
w: 8,
|
|
346
|
+
h: 16
|
|
347
|
+
},
|
|
348
|
+
"folded-document": {
|
|
349
|
+
w: 8,
|
|
350
|
+
h: 12
|
|
351
|
+
},
|
|
352
|
+
cloud: {
|
|
353
|
+
w: 28,
|
|
354
|
+
h: 24
|
|
355
|
+
},
|
|
356
|
+
circle: {
|
|
357
|
+
w: 12,
|
|
358
|
+
h: 12
|
|
359
|
+
},
|
|
360
|
+
ellipse: {
|
|
361
|
+
w: 16,
|
|
362
|
+
h: 16
|
|
363
|
+
},
|
|
364
|
+
boundary: {
|
|
365
|
+
w: 8,
|
|
366
|
+
h: 8
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
/** Left icon column width for card nodes (matches SVG placement). */
|
|
370
|
+
function cardIconColumnWidth(iconId, scale = 1) {
|
|
371
|
+
const height = CARD_ICON_HEIGHT * scale;
|
|
372
|
+
const { width } = iconDisplaySize(resolveIcon(iconId) ?? {
|
|
373
|
+
width: 1,
|
|
374
|
+
height: 1
|
|
375
|
+
}, height);
|
|
376
|
+
return Math.max(MIN_ICON_COL * scale, width);
|
|
377
|
+
}
|
|
378
|
+
function measureGraph(graph, measurer = defaultMeasurer, options = {}) {
|
|
379
|
+
const start = performance.now();
|
|
380
|
+
const nodes = [];
|
|
381
|
+
for (const node of graph.nodes) {
|
|
382
|
+
const shape = normalizeShapeId(node.shape ?? "rounded");
|
|
383
|
+
const geometry = resolveShapeGeometry(shape);
|
|
384
|
+
const cardShape = shape === "rounded" || shape === "rectangle" || shape === "pill";
|
|
385
|
+
const hasIcon = Boolean(node.icon && node.icon !== "none");
|
|
386
|
+
const { defaults: kindDefaults } = getKindDefaults(node.kind);
|
|
387
|
+
const iconOnly = kindHasCapability(node.kind, "icon-only") || kindDefaults.capabilities.includes("icon-only");
|
|
388
|
+
const scale = node.scale && node.scale > 0 ? node.scale : 1;
|
|
389
|
+
const fontSize = 15 * scale;
|
|
390
|
+
const padX = PADDING_X * scale;
|
|
391
|
+
const padY = (Boolean(node.technology && node.technology.trim() || node.description && node.description.trim()) ? RICH_PADDING_Y : PADDING_Y) * scale;
|
|
392
|
+
const minBoxH = 56 * scale;
|
|
393
|
+
const effectiveMinW = (node.minWidth ?? kindDefaults.defaultMinWidth) * (node.minWidth ? 1 : scale);
|
|
394
|
+
const effectiveMaxW = (node.maxWidth ?? kindDefaults.defaultMaxWidth) * (node.maxWidth ? 1 : scale);
|
|
395
|
+
if (iconOnly) {
|
|
396
|
+
const caption = node.labelAuthored ? measurer.wrapText(node.label, {
|
|
397
|
+
maxWidth: effectiveMaxW - padX,
|
|
398
|
+
style: {
|
|
399
|
+
fontSize: fontSize * .72,
|
|
400
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
401
|
+
fontWeight: "700"
|
|
402
|
+
}
|
|
403
|
+
}) : {
|
|
404
|
+
lines: [],
|
|
405
|
+
width: 0,
|
|
406
|
+
height: 0
|
|
407
|
+
};
|
|
408
|
+
const side = Math.max(effectiveMinW, Math.min(effectiveMaxW, ICON_ONLY_SIZE * scale + (caption.height > 0 ? ICON_ONLY_CAPTION * scale : 0)));
|
|
409
|
+
const height = Math.max(side, ICON_ONLY_SIZE * scale + caption.height);
|
|
410
|
+
nodes.push({
|
|
411
|
+
nodeId: node.id,
|
|
412
|
+
width: side,
|
|
413
|
+
height,
|
|
414
|
+
contentBox: relativeContentBox(geometry, side, height),
|
|
415
|
+
labelLines: caption.lines
|
|
416
|
+
});
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (shape === "table" && node.columns && node.columns.length > 0) {
|
|
420
|
+
const table = measureTableNode(node, measurer, scale, effectiveMinW, effectiveMaxW);
|
|
421
|
+
nodes.push({
|
|
422
|
+
nodeId: node.id,
|
|
423
|
+
width: table.width,
|
|
424
|
+
height: table.height,
|
|
425
|
+
contentBox: relativeContentBox(geometry, table.width, table.height),
|
|
426
|
+
labelLines: table.labelLines
|
|
427
|
+
});
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (shape === "diamond") {
|
|
431
|
+
const innerMaxW = (effectiveMaxW - padX * 2) * .58;
|
|
432
|
+
const wrapped = measurer.wrapText(node.label, {
|
|
433
|
+
maxWidth: innerMaxW,
|
|
434
|
+
style: {
|
|
435
|
+
fontSize,
|
|
436
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
437
|
+
fontWeight: "800"
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
const contentNeed = {
|
|
441
|
+
width: Math.max(...wrapped.lines.map((line) => measurer.measureText(line, {
|
|
442
|
+
fontSize,
|
|
443
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
444
|
+
fontWeight: "800"
|
|
445
|
+
}).width), wrapped.width) + padX * 2,
|
|
446
|
+
height: wrapped.height + padY * 2
|
|
447
|
+
};
|
|
448
|
+
const sized = geometrySizeForContent(geometry, contentNeed, {
|
|
449
|
+
width: Math.max(effectiveMinW, contentNeed.width * 1.7),
|
|
450
|
+
height: Math.max(80 * scale, contentNeed.height * 1.9)
|
|
451
|
+
});
|
|
452
|
+
const width = Math.max(effectiveMinW, sized.width);
|
|
453
|
+
const height = Math.max(80 * scale, sized.height);
|
|
454
|
+
nodes.push({
|
|
455
|
+
nodeId: node.id,
|
|
456
|
+
width,
|
|
457
|
+
height,
|
|
458
|
+
contentBox: relativeContentBox(geometry, width, height),
|
|
459
|
+
labelLines: wrapped.lines
|
|
460
|
+
});
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const iconCol = cardShape && hasIcon && node.icon ? cardIconColumnWidth(node.icon, scale) : 0;
|
|
464
|
+
const wrapped = measurer.wrapText(node.label, {
|
|
465
|
+
maxWidth: effectiveMaxW - padX * 2 - iconCol,
|
|
466
|
+
style: {
|
|
467
|
+
fontSize,
|
|
468
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
469
|
+
fontWeight: "800"
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
const extra = SHAPE_EXTRA[shape] ?? {
|
|
473
|
+
w: 0,
|
|
474
|
+
h: 0
|
|
475
|
+
};
|
|
476
|
+
const iconW = cardShape && hasIcon ? iconCol + CARD_ICON_PAD * scale : hasIcon ? 8 * scale : 0;
|
|
477
|
+
const iconH = hasIcon && !cardShape ? ICON_BLOCK * scale : 0;
|
|
478
|
+
const authoredSubtitle = node.subtitle?.trim() ? node.subtitle.trim() : void 0;
|
|
479
|
+
const wantsSubtitle = Boolean(authoredSubtitle) || node.showSubtitle === true || options.reserveKindSubtitles;
|
|
480
|
+
const subtitleH = wantsSubtitle ? SUBTITLE_HEIGHT * scale : 0;
|
|
481
|
+
let subtitleW = 0;
|
|
482
|
+
if (wantsSubtitle) {
|
|
483
|
+
const subtitleSize = fontSize * (10.5 / 15);
|
|
484
|
+
const subtitleText = (authoredSubtitle ?? kindSubtitle(node.kind)).toUpperCase();
|
|
485
|
+
subtitleW = measurer.measureText(subtitleText, {
|
|
486
|
+
fontSize: subtitleSize,
|
|
487
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
488
|
+
fontWeight: "600"
|
|
489
|
+
}).width + Math.max(0, subtitleText.length - 1) * subtitleSize * .05;
|
|
490
|
+
}
|
|
491
|
+
const noteH = node.note ? NOTE_HEIGHT * scale : 0;
|
|
492
|
+
let noteW = 0;
|
|
493
|
+
if (node.note) noteW = measurer.measureText(node.note, {
|
|
494
|
+
fontSize: fontSize * (9 / 15),
|
|
495
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
496
|
+
fontWeight: "400"
|
|
497
|
+
}).width;
|
|
498
|
+
const technology = node.technology?.trim() ? node.technology.trim() : void 0;
|
|
499
|
+
const description = node.description?.trim() ? node.description.trim() : void 0;
|
|
500
|
+
const textMaxW = effectiveMaxW - padX * 2 - iconCol;
|
|
501
|
+
const sectionGap = SECTION_GAP * scale;
|
|
502
|
+
let technologyLines;
|
|
503
|
+
let technologyW = 0;
|
|
504
|
+
let technologyH = 0;
|
|
505
|
+
if (technology) {
|
|
506
|
+
const techSize = fontSize * (11 / 15);
|
|
507
|
+
const techWrap = measurer.wrapText(technology, {
|
|
508
|
+
maxWidth: textMaxW,
|
|
509
|
+
style: {
|
|
510
|
+
fontSize: techSize,
|
|
511
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
512
|
+
fontWeight: "500"
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
technologyLines = techWrap.lines;
|
|
516
|
+
technologyW = techWrap.width;
|
|
517
|
+
technologyH = technologyLines.length * TECHNOLOGY_HEIGHT * scale;
|
|
518
|
+
}
|
|
519
|
+
let descriptionLines;
|
|
520
|
+
let descriptionW = 0;
|
|
521
|
+
let descriptionH = 0;
|
|
522
|
+
if (description) {
|
|
523
|
+
const descSize = fontSize * (11 / 15);
|
|
524
|
+
const descWrap = measurer.wrapText(description, {
|
|
525
|
+
maxWidth: textMaxW,
|
|
526
|
+
style: {
|
|
527
|
+
fontSize: descSize,
|
|
528
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
529
|
+
fontWeight: "500"
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
descriptionLines = descWrap.lines;
|
|
533
|
+
descriptionW = descWrap.width;
|
|
534
|
+
descriptionH = descriptionLines.length * DESCRIPTION_LINE_HEIGHT * scale;
|
|
535
|
+
}
|
|
536
|
+
const afterSubtitleGap = technologyLines || descriptionLines ? sectionGap : 0;
|
|
537
|
+
const afterTechGap = technologyLines && descriptionLines ? sectionGap : 0;
|
|
538
|
+
const personIconH = shape === "person" && hasIcon ? ICON_BLOCK * scale : shape === "person" ? 0 : iconH;
|
|
539
|
+
const textStackH = wrapped.height + subtitleH + afterSubtitleGap + technologyH + afterTechGap + descriptionH + noteH;
|
|
540
|
+
const contentW = Math.max(wrapped.width, subtitleW, technologyW, descriptionW, noteW) + padX * 2 + extra.w * scale + iconW;
|
|
541
|
+
const contentH = textStackH + padY * 2 + extra.h * scale + personIconH;
|
|
542
|
+
let width = Math.max(effectiveMinW, contentW);
|
|
543
|
+
const stretch = contentW > 0 && width > contentW ? Math.min(width / contentW, 1.45) : 1;
|
|
544
|
+
let height = Math.max(minBoxH, contentH * stretch);
|
|
545
|
+
if (shape === "hexagon" || shape === "queue") height = Math.max(height, width * .44);
|
|
546
|
+
if (shape === "cylinder") height = Math.max(height, Math.min(width * .55, 96 * scale));
|
|
547
|
+
if (cardShape) {
|
|
548
|
+
const sized = geometrySizeForContent(geometry, {
|
|
549
|
+
width: Math.max(wrapped.width, subtitleW, technologyW, descriptionW, noteW) + iconW,
|
|
550
|
+
height: textStackH
|
|
551
|
+
}, {
|
|
552
|
+
width,
|
|
553
|
+
height
|
|
554
|
+
});
|
|
555
|
+
width = Math.max(width, sized.width);
|
|
556
|
+
height = Math.max(height, sized.height);
|
|
557
|
+
}
|
|
558
|
+
if (shape === "cloud") width = Math.max(width, height * 1.5);
|
|
559
|
+
if (shape === "person") {
|
|
560
|
+
const bodyW = Math.max(effectiveMinW, contentW);
|
|
561
|
+
const bodyH = Math.max(56 * scale, textStackH + padY * 2 + personIconH);
|
|
562
|
+
const bodyHalfW = Math.max(18, bodyW / 2 - 2);
|
|
563
|
+
const headR = Math.max(10, Math.min(22, bodyHalfW * .38));
|
|
564
|
+
const headStack = headR * 2 + Math.max(2, headR * .18) + 6;
|
|
565
|
+
width = bodyW;
|
|
566
|
+
height = headStack + bodyH;
|
|
567
|
+
}
|
|
568
|
+
if (shape === "circle") {
|
|
569
|
+
const side = Math.max(width, height);
|
|
570
|
+
width = side;
|
|
571
|
+
height = side;
|
|
572
|
+
}
|
|
573
|
+
nodes.push({
|
|
574
|
+
nodeId: node.id,
|
|
575
|
+
width,
|
|
576
|
+
height,
|
|
577
|
+
contentBox: relativeContentBox(geometry, width, height),
|
|
578
|
+
labelLines: wrapped.lines,
|
|
579
|
+
technologyLines,
|
|
580
|
+
descriptionLines
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
nodes,
|
|
585
|
+
measureMs: performance.now() - start
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/layout/constants.ts
|
|
590
|
+
const DENSITY_GAP = {
|
|
591
|
+
compact: 56,
|
|
592
|
+
normal: 88,
|
|
593
|
+
spacious: 116
|
|
594
|
+
};
|
|
595
|
+
const DEFAULT_GROUP_PADDING = {
|
|
596
|
+
top: 56,
|
|
597
|
+
right: 40,
|
|
598
|
+
bottom: 40,
|
|
599
|
+
left: 40
|
|
600
|
+
};
|
|
601
|
+
/** Chromeless layout planes — no label headroom. */
|
|
602
|
+
const LAYOUT_ONLY_GROUP_PADDING = {
|
|
603
|
+
top: 20,
|
|
604
|
+
right: 20,
|
|
605
|
+
bottom: 20,
|
|
606
|
+
left: 20
|
|
607
|
+
};
|
|
608
|
+
const PADDING_HINTS = {
|
|
609
|
+
compact: {
|
|
610
|
+
top: 36,
|
|
611
|
+
right: 24,
|
|
612
|
+
bottom: 24,
|
|
613
|
+
left: 24
|
|
614
|
+
},
|
|
615
|
+
normal: {
|
|
616
|
+
top: 56,
|
|
617
|
+
right: 40,
|
|
618
|
+
bottom: 40,
|
|
619
|
+
left: 40
|
|
620
|
+
},
|
|
621
|
+
spacious: {
|
|
622
|
+
top: 88,
|
|
623
|
+
right: 60,
|
|
624
|
+
bottom: 60,
|
|
625
|
+
left: 60
|
|
626
|
+
},
|
|
627
|
+
/** Iso-pixel platforms need extra pad so chunky cards + pipes clear the rim. */
|
|
628
|
+
pixel: {
|
|
629
|
+
top: 100,
|
|
630
|
+
right: 80,
|
|
631
|
+
bottom: 80,
|
|
632
|
+
left: 80
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/layout/group-bounds.ts
|
|
637
|
+
const GROUP_LABEL_FONT_SIZE = 11;
|
|
638
|
+
const GROUP_LABEL_PADDING_X = 14;
|
|
639
|
+
const GROUP_LABEL_PADDING_Y = 10;
|
|
640
|
+
const GROUP_ICON_SIZE = 14;
|
|
641
|
+
const GROUP_ICON_GAP = 6;
|
|
642
|
+
function paddingForGroup(group) {
|
|
643
|
+
const chromeless = group.chrome === false;
|
|
644
|
+
if (group.paddingHint && PADDING_HINTS[group.paddingHint]) {
|
|
645
|
+
const p = PADDING_HINTS[group.paddingHint];
|
|
646
|
+
if (chromeless) return {
|
|
647
|
+
top: p.left,
|
|
648
|
+
right: p.right,
|
|
649
|
+
bottom: p.bottom,
|
|
650
|
+
left: p.left
|
|
651
|
+
};
|
|
652
|
+
return p;
|
|
653
|
+
}
|
|
654
|
+
if (group.paddingHint != null) {
|
|
655
|
+
const n = Number(group.paddingHint);
|
|
656
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
657
|
+
const box = {
|
|
658
|
+
top: n,
|
|
659
|
+
right: n,
|
|
660
|
+
bottom: n,
|
|
661
|
+
left: n
|
|
662
|
+
};
|
|
663
|
+
if (chromeless) return {
|
|
664
|
+
top: n,
|
|
665
|
+
right: n,
|
|
666
|
+
bottom: n,
|
|
667
|
+
left: n
|
|
668
|
+
};
|
|
669
|
+
return box;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (chromeless) return LAYOUT_ONLY_GROUP_PADDING;
|
|
673
|
+
return DEFAULT_GROUP_PADDING;
|
|
674
|
+
}
|
|
675
|
+
function groupBoundsFromNodes(graph, laidOut, padding) {
|
|
676
|
+
const nodeMap = new Map(laidOut.map((n) => [n.nodeId, n]));
|
|
677
|
+
const result = /* @__PURE__ */ new Map();
|
|
678
|
+
for (const group of graph.groups) {
|
|
679
|
+
const memberBounds = group.nodeIds.map((id) => nodeMap.get(id)?.bounds).filter((b) => !!b);
|
|
680
|
+
if (!memberBounds.length) continue;
|
|
681
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
682
|
+
for (const b of memberBounds) {
|
|
683
|
+
minX = Math.min(minX, b.x);
|
|
684
|
+
minY = Math.min(minY, b.y);
|
|
685
|
+
maxX = Math.max(maxX, b.x + b.width);
|
|
686
|
+
maxY = Math.max(maxY, b.y + b.height);
|
|
687
|
+
}
|
|
688
|
+
result.set(group.id, {
|
|
689
|
+
x: minX - padding.left,
|
|
690
|
+
y: minY - padding.top,
|
|
691
|
+
width: maxX - minX + padding.left + padding.right,
|
|
692
|
+
height: maxY - minY + padding.top + padding.bottom
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
function measureGroupLabelBox(label, bounds, hasIcon = false) {
|
|
698
|
+
const text = label.toUpperCase();
|
|
699
|
+
const charWidth = GROUP_LABEL_FONT_SIZE * .5740000000000001;
|
|
700
|
+
const iconExtra = hasIcon ? 20 : 0;
|
|
701
|
+
const width = Math.min(Math.max(text.length * charWidth + 8 + iconExtra, 48), Math.max(bounds.width - 8, 48));
|
|
702
|
+
return {
|
|
703
|
+
x: bounds.x + GROUP_LABEL_PADDING_X,
|
|
704
|
+
y: bounds.y + GROUP_LABEL_PADDING_Y,
|
|
705
|
+
width,
|
|
706
|
+
height: 19
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function computeGroupBounds(graph, laidOut) {
|
|
710
|
+
const result = [];
|
|
711
|
+
for (const group of graph.groups) {
|
|
712
|
+
const padding = paddingForGroup(group);
|
|
713
|
+
const bounds = groupBoundsFromNodes(graph, laidOut, padding).get(group.id);
|
|
714
|
+
if (!bounds) continue;
|
|
715
|
+
const hasIcon = Boolean(group.icon && group.icon !== "none" && group.chrome !== false);
|
|
716
|
+
result.push({
|
|
717
|
+
groupId: group.id,
|
|
718
|
+
bounds,
|
|
719
|
+
labelBox: measureGroupLabelBox(group.label, bounds, hasIcon),
|
|
720
|
+
padding
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
return result;
|
|
724
|
+
}
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/direction/index.ts
|
|
727
|
+
/** True for left↔right flow (`LR` / `RL`). */
|
|
728
|
+
function isHorizontal(direction) {
|
|
729
|
+
return direction === "LR" || direction === "RL";
|
|
730
|
+
}
|
|
731
|
+
/** True for top↔bottom flow (`TD` / `BT`). */
|
|
732
|
+
function isVertical(direction) {
|
|
733
|
+
return direction === "TD" || direction === "BT";
|
|
734
|
+
}
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region src/topology/analyze.ts
|
|
737
|
+
function buildEdgeCounts(graph) {
|
|
738
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
739
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
740
|
+
for (const node of graph.nodes) {
|
|
741
|
+
incoming.set(node.id, 0);
|
|
742
|
+
outgoing.set(node.id, 0);
|
|
743
|
+
}
|
|
744
|
+
for (const edge of graph.edges) {
|
|
745
|
+
outgoing.set(edge.from, (outgoing.get(edge.from) ?? 0) + 1);
|
|
746
|
+
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
incoming,
|
|
750
|
+
outgoing
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function analyzeDiagramTopology(graph, direction) {
|
|
754
|
+
const { incoming, outgoing } = buildEdgeCounts(graph);
|
|
755
|
+
const choiceNodes = [];
|
|
756
|
+
const choiceBranches = /* @__PURE__ */ new Set();
|
|
757
|
+
for (const node of graph.nodes) if (node.kind === "choice" && (outgoing.get(node.id) ?? 0) >= 2) {
|
|
758
|
+
choiceNodes.push(node.id);
|
|
759
|
+
choiceBranches.add(node.id);
|
|
760
|
+
}
|
|
761
|
+
const mergeNodes = [];
|
|
762
|
+
const fanOutNodes = [];
|
|
763
|
+
for (const node of graph.nodes) {
|
|
764
|
+
if ((incoming.get(node.id) ?? 0) >= 2) mergeNodes.push(node.id);
|
|
765
|
+
if ((outgoing.get(node.id) ?? 0) >= 2) fanOutNodes.push(node.id);
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
direction,
|
|
769
|
+
isWorkflowTD: isVertical(direction) && choiceNodes.length > 0,
|
|
770
|
+
choiceNodes,
|
|
771
|
+
mergeNodes,
|
|
772
|
+
fanOutNodes,
|
|
773
|
+
choiceBranches,
|
|
774
|
+
incoming,
|
|
775
|
+
outgoing
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
function incomingCount(topology, nodeId) {
|
|
779
|
+
return topology.incoming.get(nodeId) ?? 0;
|
|
780
|
+
}
|
|
781
|
+
function outgoingCount(topology, nodeId) {
|
|
782
|
+
return topology.outgoing.get(nodeId) ?? 0;
|
|
783
|
+
}
|
|
784
|
+
function isChoiceBranch(topology, nodeId) {
|
|
785
|
+
return topology.choiceBranches.has(nodeId);
|
|
786
|
+
}
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/layout/elk/edge-priority.ts
|
|
789
|
+
function layoutBranchCue(edge) {
|
|
790
|
+
return edge.branch ?? classifyBranch(edge.label);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Prefer a straight happy-path spine; keep exception edges short instead of scenic.
|
|
794
|
+
* Explicit GraphEdge.priority amplifies or dampens the cue.
|
|
795
|
+
*/
|
|
796
|
+
function elkPriorityOptionsForEdge(edge) {
|
|
797
|
+
const cue = layoutBranchCue(edge);
|
|
798
|
+
let straightness = 3;
|
|
799
|
+
let shortness = 3;
|
|
800
|
+
if (edge.kind === "failure" || cue === "no") {
|
|
801
|
+
straightness = 1;
|
|
802
|
+
shortness = 9;
|
|
803
|
+
} else if (cue === "yes") {
|
|
804
|
+
straightness = 9;
|
|
805
|
+
shortness = 5;
|
|
806
|
+
} else if (!edge.label) {
|
|
807
|
+
straightness = 7;
|
|
808
|
+
shortness = 4;
|
|
809
|
+
}
|
|
810
|
+
if (edge.priority === "high") {
|
|
811
|
+
straightness += 3;
|
|
812
|
+
shortness += 2;
|
|
813
|
+
} else if (edge.priority === "low") {
|
|
814
|
+
straightness = Math.max(0, straightness - 4);
|
|
815
|
+
if (cue !== "no" && edge.kind !== "failure") shortness = Math.max(1, shortness - 1);
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
"elk.layered.priority.straightness": String(straightness),
|
|
819
|
+
"elk.layered.priority.shortness": String(shortness)
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
//#endregion
|
|
823
|
+
//#region src/layout/elk/elk-ports.ts
|
|
824
|
+
function flowExitSide(direction) {
|
|
825
|
+
switch (direction) {
|
|
826
|
+
case "BT": return "NORTH";
|
|
827
|
+
case "LR": return "EAST";
|
|
828
|
+
case "RL": return "WEST";
|
|
829
|
+
default: return "SOUTH";
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function flowEntrySide(direction) {
|
|
833
|
+
switch (direction) {
|
|
834
|
+
case "BT": return "SOUTH";
|
|
835
|
+
case "LR": return "WEST";
|
|
836
|
+
case "RL": return "EAST";
|
|
837
|
+
default: return "NORTH";
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function elkSideToGeometry(side) {
|
|
841
|
+
switch (side) {
|
|
842
|
+
case "NORTH": return "north";
|
|
843
|
+
case "EAST": return "east";
|
|
844
|
+
case "WEST": return "west";
|
|
845
|
+
default: return "south";
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function outPortId(nodeId, edgeId) {
|
|
849
|
+
return `${nodeId}:out:${edgeId}`;
|
|
850
|
+
}
|
|
851
|
+
function inPortId(nodeId, edgeId) {
|
|
852
|
+
return `${nodeId}:in:${edgeId}`;
|
|
853
|
+
}
|
|
854
|
+
/** Geometry-authored 1×1 FIXED_POS port centered on getPortPosition. */
|
|
855
|
+
function makeGeometryPort(id, side, index, count, shapeId, width, height) {
|
|
856
|
+
const geometry = resolveShapeGeometry(normalizeShapeId(shapeId));
|
|
857
|
+
const local = {
|
|
858
|
+
x: 0,
|
|
859
|
+
y: 0,
|
|
860
|
+
width,
|
|
861
|
+
height
|
|
862
|
+
};
|
|
863
|
+
const pos = geometry.getPortPosition({
|
|
864
|
+
kind: "side",
|
|
865
|
+
side: elkSideToGeometry(side),
|
|
866
|
+
index,
|
|
867
|
+
count
|
|
868
|
+
}, local);
|
|
869
|
+
return {
|
|
870
|
+
id,
|
|
871
|
+
x: pos.x - .5,
|
|
872
|
+
y: pos.y - .5,
|
|
873
|
+
width: 1,
|
|
874
|
+
height: 1,
|
|
875
|
+
layoutOptions: {
|
|
876
|
+
"elk.port.side": side,
|
|
877
|
+
"elk.port.index": String(index)
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function outRank(edge) {
|
|
882
|
+
const cue = layoutBranchCue(edge);
|
|
883
|
+
if (cue === "yes") return 0;
|
|
884
|
+
if (cue === "no") return 2;
|
|
885
|
+
return 1;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Edges that participate in a 2-cycle (A→B and B→A).
|
|
889
|
+
* These stay node→node so ELK free-attaches parallel corridor shafts.
|
|
890
|
+
*/
|
|
891
|
+
function mutualEdgeIds(graph) {
|
|
892
|
+
const ids = /* @__PURE__ */ new Set();
|
|
893
|
+
for (const edge of graph.edges) {
|
|
894
|
+
if (edge.from === edge.to) continue;
|
|
895
|
+
if (graph.edges.some((o) => o.id !== edge.id && o.from === edge.to && o.to === edge.from)) ids.add(edge.id);
|
|
896
|
+
}
|
|
897
|
+
return ids;
|
|
898
|
+
}
|
|
899
|
+
function materializeFacePorts(specs, shapeId, width, height) {
|
|
900
|
+
const bySide = /* @__PURE__ */ new Map();
|
|
901
|
+
for (const spec of specs) {
|
|
902
|
+
const list = bySide.get(spec.side) ?? [];
|
|
903
|
+
list.push(spec);
|
|
904
|
+
bySide.set(spec.side, list);
|
|
905
|
+
}
|
|
906
|
+
const ports = [];
|
|
907
|
+
for (const [side, list] of bySide) {
|
|
908
|
+
list.sort((a, b) => a.rank - b.rank || a.edgeId.localeCompare(b.edgeId));
|
|
909
|
+
const count = list.length;
|
|
910
|
+
list.forEach((spec, index) => {
|
|
911
|
+
ports.push(makeGeometryPort(spec.id, side, index, count, shapeId, width, height));
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return ports;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Assign one out-port and one in-port per non-mutual edge.
|
|
918
|
+
* Positions are distributed per face so N edges on a face ⇒ N distinct pins.
|
|
919
|
+
*/
|
|
920
|
+
function assignEdgePorts(graph, measured, direction) {
|
|
921
|
+
const measureMap = new Map(measured.map((m) => [m.nodeId, m]));
|
|
922
|
+
const shapeByNode = new Map(graph.nodes.map((n) => [n.id, n.shape]));
|
|
923
|
+
const exit = flowExitSide(direction);
|
|
924
|
+
const entry = flowEntrySide(direction);
|
|
925
|
+
const mutual = mutualEdgeIds(graph);
|
|
926
|
+
const outSpecsByNode = /* @__PURE__ */ new Map();
|
|
927
|
+
const inSpecsByNode = /* @__PURE__ */ new Map();
|
|
928
|
+
const edgeSourcePort = /* @__PURE__ */ new Map();
|
|
929
|
+
const edgeTargetPort = /* @__PURE__ */ new Map();
|
|
930
|
+
const push = (map, nodeId, spec) => {
|
|
931
|
+
const list = map.get(nodeId) ?? [];
|
|
932
|
+
list.push(spec);
|
|
933
|
+
map.set(nodeId, list);
|
|
934
|
+
};
|
|
935
|
+
for (const edge of graph.edges) {
|
|
936
|
+
if (mutual.has(edge.id)) continue;
|
|
937
|
+
const sourceId = outPortId(edge.from, edge.id);
|
|
938
|
+
const targetId = inPortId(edge.to, edge.id);
|
|
939
|
+
edgeSourcePort.set(edge.id, sourceId);
|
|
940
|
+
edgeTargetPort.set(edge.id, targetId);
|
|
941
|
+
push(outSpecsByNode, edge.from, {
|
|
942
|
+
id: sourceId,
|
|
943
|
+
edgeId: edge.id,
|
|
944
|
+
side: exit,
|
|
945
|
+
rank: outRank(edge)
|
|
946
|
+
});
|
|
947
|
+
push(inSpecsByNode, edge.to, {
|
|
948
|
+
id: targetId,
|
|
949
|
+
edgeId: edge.id,
|
|
950
|
+
side: entry,
|
|
951
|
+
rank: 1
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
const portsByNode = /* @__PURE__ */ new Map();
|
|
955
|
+
const portedNodes = /* @__PURE__ */ new Set();
|
|
956
|
+
for (const node of graph.nodes) {
|
|
957
|
+
const m = measureMap.get(node.id);
|
|
958
|
+
if (!m) continue;
|
|
959
|
+
const outs = outSpecsByNode.get(node.id) ?? [];
|
|
960
|
+
const inns = inSpecsByNode.get(node.id) ?? [];
|
|
961
|
+
if (!outs.length && !inns.length) continue;
|
|
962
|
+
portedNodes.add(node.id);
|
|
963
|
+
const shapeId = shapeByNode.get(node.id);
|
|
964
|
+
portsByNode.set(node.id, [...materializeFacePorts(outs, shapeId, m.width, m.height), ...materializeFacePorts(inns, shapeId, m.width, m.height)]);
|
|
965
|
+
}
|
|
966
|
+
return {
|
|
967
|
+
portsByNode,
|
|
968
|
+
edgeSourcePort,
|
|
969
|
+
edgeTargetPort,
|
|
970
|
+
portedNodes
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
//#endregion
|
|
974
|
+
//#region src/layout/elk/polish-edges.ts
|
|
975
|
+
const COLINEAR_EPS = .75;
|
|
976
|
+
/** Drop redundant colinear waypoints from orthogonal ELK polylines.
|
|
977
|
+
* Unlike route-orthogonal-avoid's search collapse, this may drop stubs once
|
|
978
|
+
* attach points are frozen by snapEdgeEndpointsToGeometry.
|
|
979
|
+
*/
|
|
980
|
+
function collapseColinearPoints(points, eps = COLINEAR_EPS) {
|
|
981
|
+
if (points.length < 3) return points;
|
|
982
|
+
const out = [points[0]];
|
|
983
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
984
|
+
const a = out[out.length - 1];
|
|
985
|
+
const b = points[i];
|
|
986
|
+
const c = points[i + 1];
|
|
987
|
+
const vertical = Math.abs(a.x - b.x) < eps && Math.abs(b.x - c.x) < eps;
|
|
988
|
+
const horizontal = Math.abs(a.y - b.y) < eps && Math.abs(b.y - c.y) < eps;
|
|
989
|
+
if (vertical || horizontal) continue;
|
|
990
|
+
out.push(b);
|
|
991
|
+
}
|
|
992
|
+
out.push(points[points.length - 1]);
|
|
993
|
+
return out;
|
|
994
|
+
}
|
|
995
|
+
function rangesOverlap$1(a0, a1, b0, b1) {
|
|
996
|
+
return Math.min(a0, a1) <= Math.max(b0, b1) && Math.max(a0, a1) >= Math.min(b0, b1);
|
|
997
|
+
}
|
|
998
|
+
/** Move segment endpoints that are not path termini (attach points stay fixed). */
|
|
999
|
+
function setSegmentCoord(out, segIndex, axis, value) {
|
|
1000
|
+
const i = segIndex;
|
|
1001
|
+
const last = out.length - 1;
|
|
1002
|
+
if (i > 0) out[i][axis] = value;
|
|
1003
|
+
if (i + 1 < last) out[i + 1][axis] = value;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Shift orthogonal corridors that run inside the keep-out around node cards
|
|
1007
|
+
* so 90° corners aren't parked flush on a shape edge.
|
|
1008
|
+
* Path endpoints (port attaches) are never moved.
|
|
1009
|
+
*/
|
|
1010
|
+
function clearOrthogonalCorridors(points, nodes, pad) {
|
|
1011
|
+
if (points.length < 3 || nodes.length === 0 || pad <= 0) return points;
|
|
1012
|
+
const out = points.map((p) => ({
|
|
1013
|
+
x: p.x,
|
|
1014
|
+
y: p.y
|
|
1015
|
+
}));
|
|
1016
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
1017
|
+
let moved = false;
|
|
1018
|
+
for (let i = 0; i < out.length - 1; i++) {
|
|
1019
|
+
if (i === 0 || i === out.length - 2) continue;
|
|
1020
|
+
const a = out[i];
|
|
1021
|
+
const b = out[i + 1];
|
|
1022
|
+
const vert = Math.abs(a.x - b.x) < COLINEAR_EPS;
|
|
1023
|
+
const horz = Math.abs(a.y - b.y) < COLINEAR_EPS;
|
|
1024
|
+
if (!vert && !horz) continue;
|
|
1025
|
+
for (const box of nodes) {
|
|
1026
|
+
const L = box.x;
|
|
1027
|
+
const R = box.x + box.width;
|
|
1028
|
+
const T = box.y;
|
|
1029
|
+
const B = box.y + box.height;
|
|
1030
|
+
if (vert) {
|
|
1031
|
+
const x = (a.x + b.x) / 2;
|
|
1032
|
+
if (!rangesOverlap$1(a.y, b.y, T - pad, B + pad)) continue;
|
|
1033
|
+
if (x > L - pad && x < L) {
|
|
1034
|
+
setSegmentCoord(out, i, "x", L - pad);
|
|
1035
|
+
moved = true;
|
|
1036
|
+
} else if (x > R && x < R + pad) {
|
|
1037
|
+
setSegmentCoord(out, i, "x", R + pad);
|
|
1038
|
+
moved = true;
|
|
1039
|
+
}
|
|
1040
|
+
} else {
|
|
1041
|
+
const y = (a.y + b.y) / 2;
|
|
1042
|
+
if (!rangesOverlap$1(a.x, b.x, L - pad, R + pad)) continue;
|
|
1043
|
+
if (y > T - pad && y < T) {
|
|
1044
|
+
setSegmentCoord(out, i, "y", T - pad);
|
|
1045
|
+
moved = true;
|
|
1046
|
+
} else if (y > B && y < B + pad) {
|
|
1047
|
+
setSegmentCoord(out, i, "y", B + pad);
|
|
1048
|
+
moved = true;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (!moved) break;
|
|
1054
|
+
}
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Repair any residual non-orthogonal stubs (e.g. ELK diagonal tips) by inserting
|
|
1059
|
+
* one corner so metro/rounded stroke rendering never draws a slash to the port.
|
|
1060
|
+
*/
|
|
1061
|
+
function ensureOrthogonalPoints(points, eps = COLINEAR_EPS) {
|
|
1062
|
+
if (points.length < 2) return points;
|
|
1063
|
+
const out = [{ ...points[0] }];
|
|
1064
|
+
for (let i = 1; i < points.length; i++) {
|
|
1065
|
+
const prev = out[out.length - 1];
|
|
1066
|
+
const curr = points[i];
|
|
1067
|
+
const dx = Math.abs(curr.x - prev.x);
|
|
1068
|
+
const dy = Math.abs(curr.y - prev.y);
|
|
1069
|
+
if (dx > eps && dy > eps) {
|
|
1070
|
+
const before = out.length >= 2 ? out[out.length - 2] : null;
|
|
1071
|
+
const preferVertical = before != null ? Math.abs(before.x - prev.x) < eps : dy >= dx;
|
|
1072
|
+
out.push(preferVertical ? {
|
|
1073
|
+
x: prev.x,
|
|
1074
|
+
y: curr.y
|
|
1075
|
+
} : {
|
|
1076
|
+
x: curr.x,
|
|
1077
|
+
y: prev.y
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
out.push({ ...curr });
|
|
1081
|
+
}
|
|
1082
|
+
return collapseColinearPoints(out, eps);
|
|
1083
|
+
}
|
|
1084
|
+
/** Light cleanup — preserve ELK topology; keep bends clear of node cards. */
|
|
1085
|
+
function polishEdgePaths(paths, nodes = [], edgeNodeClearance = 28) {
|
|
1086
|
+
const pad = Math.max(28, edgeNodeClearance);
|
|
1087
|
+
return paths.map((path) => ({
|
|
1088
|
+
...path,
|
|
1089
|
+
points: ensureOrthogonalPoints(collapseColinearPoints(clearOrthogonalCorridors(path.points, nodes, pad)))
|
|
1090
|
+
}));
|
|
1091
|
+
}
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/layout/elk/build-elk-graph.ts
|
|
1094
|
+
/** Defaults chosen for flowchart readability — override via LayoutOptions. */
|
|
1095
|
+
const DEFAULT_NODE_PLACEMENT = "balanced";
|
|
1096
|
+
const ELK_NODE_PLACEMENT = {
|
|
1097
|
+
straight: "BRANDES_KOEPF",
|
|
1098
|
+
balanced: "NETWORK_SIMPLEX",
|
|
1099
|
+
basic: "SIMPLE"
|
|
1100
|
+
};
|
|
1101
|
+
const DEFAULT_THOROUGHNESS = 24;
|
|
1102
|
+
function elkDirection(direction) {
|
|
1103
|
+
switch (direction) {
|
|
1104
|
+
case "RL": return "LEFT";
|
|
1105
|
+
case "TD": return "DOWN";
|
|
1106
|
+
case "BT": return "UP";
|
|
1107
|
+
default: return "RIGHT";
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
function spacing(options) {
|
|
1111
|
+
const density = options.density ?? "normal";
|
|
1112
|
+
const scale = options.spacingScale ?? 1;
|
|
1113
|
+
const gap = DENSITY_GAP[density] ?? DENSITY_GAP.normal;
|
|
1114
|
+
return Math.round(gap * scale);
|
|
1115
|
+
}
|
|
1116
|
+
function aspectRatioFor(direction) {
|
|
1117
|
+
return direction === "TD" || direction === "BT" ? "0.72" : "1.55";
|
|
1118
|
+
}
|
|
1119
|
+
/** Resolve ELK edge endpoint ref (node id or `node:…:edge`) to owning node id. */
|
|
1120
|
+
function endpointNodeId(ref) {
|
|
1121
|
+
const colon = ref.indexOf(":");
|
|
1122
|
+
if (colon <= 0) return ref;
|
|
1123
|
+
return ref.slice(0, colon);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Build an ELK JSON graph from Kekonic Diagrams measure + membership.
|
|
1127
|
+
* Maps semantic GraphModel → ELK; no raw elk bags on Graph JSON.
|
|
1128
|
+
*
|
|
1129
|
+
* Every edge is wired port→port: source on the flow-exit face, target on the
|
|
1130
|
+
* flow-entry face, with per-edge FIXED_POS pins from ShapeGeometry so fan-in
|
|
1131
|
+
* and fan-out never share a single attach point.
|
|
1132
|
+
*/
|
|
1133
|
+
function buildElkGraph(graph, measured, options) {
|
|
1134
|
+
const measureMap = new Map(measured.map((m) => [m.nodeId, m]));
|
|
1135
|
+
const gap = spacing(options);
|
|
1136
|
+
const groupGap = options.groupGap ?? 72;
|
|
1137
|
+
const direction = options.direction ?? "LR";
|
|
1138
|
+
const edgeNode = Math.max(28, options.edgeNodeSpacing ?? Math.round(gap * .6));
|
|
1139
|
+
const edgeEdge = Math.max(12, options.edgeEdgeSpacing ?? Math.max(18, Math.round(gap * .36)));
|
|
1140
|
+
const nodeNode = options.groupGap != null ? groupGap : gap;
|
|
1141
|
+
const layerGap = options.groupGap != null ? Math.round(groupGap * .9) : gap;
|
|
1142
|
+
const nodePlacement = ELK_NODE_PLACEMENT[options.nodePlacement ?? DEFAULT_NODE_PLACEMENT] ?? ELK_NODE_PLACEMENT.straight;
|
|
1143
|
+
const topology = analyzeDiagramTopology(graph, direction);
|
|
1144
|
+
const portAssignment = assignEdgePorts(graph, measured, direction);
|
|
1145
|
+
const topSwimlanes = graph.groups.filter((g) => g.kind === "swimlane" && g.parentId == null);
|
|
1146
|
+
const usePartitions = options.groupLayout === "swimlane" || topSwimlanes.length > 0;
|
|
1147
|
+
const flatMembership = options.groupLayout === "flat" || usePartitions && options.groupLayout === "swimlane";
|
|
1148
|
+
const hierarchy = flatMembership ? "SEPARATE_CHILDREN" : "INCLUDE_CHILDREN";
|
|
1149
|
+
const swimlanePartition = /* @__PURE__ */ new Map();
|
|
1150
|
+
topSwimlanes.forEach((g, i) => {
|
|
1151
|
+
for (const nodeId of g.nodeIds) swimlanePartition.set(nodeId, i);
|
|
1152
|
+
const stack = [...g.childGroupIds];
|
|
1153
|
+
while (stack.length) {
|
|
1154
|
+
const cid = stack.pop();
|
|
1155
|
+
const child = graph.groups.find((x) => x.id === cid);
|
|
1156
|
+
if (!child) continue;
|
|
1157
|
+
for (const nodeId of child.nodeIds) swimlanePartition.set(nodeId, i);
|
|
1158
|
+
stack.push(...child.childGroupIds);
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
const defaultPartition = topSwimlanes.length;
|
|
1162
|
+
const incoming = topology.incoming;
|
|
1163
|
+
const outgoing = topology.outgoing;
|
|
1164
|
+
const rootLayoutOptions = {
|
|
1165
|
+
"elk.algorithm": "layered",
|
|
1166
|
+
"elk.direction": elkDirection(direction),
|
|
1167
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
1168
|
+
"elk.hierarchyHandling": hierarchy,
|
|
1169
|
+
"elk.aspectRatio": aspectRatioFor(direction),
|
|
1170
|
+
"elk.layered.thoroughness": String(DEFAULT_THOROUGHNESS),
|
|
1171
|
+
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
|
1172
|
+
"elk.layered.nodePlacement.strategy": nodePlacement,
|
|
1173
|
+
"elk.layered.nodePlacement.bk.edgeStraightening": "IMPROVE_STRAIGHTNESS",
|
|
1174
|
+
"elk.layered.nodePlacement.favorStraightEdges": "true",
|
|
1175
|
+
"elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH",
|
|
1176
|
+
"elk.layered.unnecessaryBendpoints": "false",
|
|
1177
|
+
"elk.layered.feedbackEdges": "false",
|
|
1178
|
+
"elk.layered.considerModelOrder.strategy": options.considerModelOrder === false ? "NONE" : "NODES_AND_EDGES",
|
|
1179
|
+
"elk.padding": `[top=64,left=64,bottom=64,right=64]`,
|
|
1180
|
+
"elk.spacing.nodeNode": String(nodeNode),
|
|
1181
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(layerGap),
|
|
1182
|
+
"elk.spacing.edgeNode": String(edgeNode),
|
|
1183
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": String(edgeNode),
|
|
1184
|
+
"elk.spacing.edgeEdge": String(edgeEdge),
|
|
1185
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": String(edgeEdge),
|
|
1186
|
+
"elk.spacing.componentComponent": String(groupGap)
|
|
1187
|
+
};
|
|
1188
|
+
if (usePartitions) rootLayoutOptions["elk.partitioning.activate"] = "true";
|
|
1189
|
+
if (graph.edges.some((e) => {
|
|
1190
|
+
return graph.nodes.find((n) => n.id === e.from)?.groupId !== graph.nodes.find((n) => n.id === e.to)?.groupId;
|
|
1191
|
+
}) && !flatMembership) rootLayoutOptions["elk.layered.mergeHierarchyEdges"] = "true";
|
|
1192
|
+
const groupsByParent = /* @__PURE__ */ new Map();
|
|
1193
|
+
for (const g of graph.groups) {
|
|
1194
|
+
if (flatMembership) continue;
|
|
1195
|
+
const key = g.parentId;
|
|
1196
|
+
const list = groupsByParent.get(key) ?? [];
|
|
1197
|
+
list.push(g);
|
|
1198
|
+
groupsByParent.set(key, list);
|
|
1199
|
+
}
|
|
1200
|
+
const assignedNodes = /* @__PURE__ */ new Set();
|
|
1201
|
+
const nodeLayoutOptions = (nodeId) => {
|
|
1202
|
+
const opts = {};
|
|
1203
|
+
const out = outgoing.get(nodeId) ?? 0;
|
|
1204
|
+
const inn = incoming.get(nodeId) ?? 0;
|
|
1205
|
+
if (out === 0 && inn > 0) opts["elk.layered.layering.layerConstraint"] = "LAST";
|
|
1206
|
+
if (inn === 0 && out > 0) opts["elk.layered.layering.layerConstraint"] = "FIRST";
|
|
1207
|
+
if (portAssignment.portedNodes.has(nodeId)) opts["elk.portConstraints"] = "FIXED_POS";
|
|
1208
|
+
if (usePartitions) opts["elk.partitioning.partition"] = String(swimlanePartition.get(nodeId) ?? defaultPartition);
|
|
1209
|
+
return opts;
|
|
1210
|
+
};
|
|
1211
|
+
const buildLeaf = (nodeId, m) => {
|
|
1212
|
+
const layoutOptions = nodeLayoutOptions(nodeId);
|
|
1213
|
+
const ports = portAssignment.portsByNode.get(nodeId);
|
|
1214
|
+
return {
|
|
1215
|
+
id: nodeId,
|
|
1216
|
+
width: m.width,
|
|
1217
|
+
height: m.height,
|
|
1218
|
+
...Object.keys(layoutOptions).length ? { layoutOptions } : {},
|
|
1219
|
+
...ports?.length ? { ports } : {}
|
|
1220
|
+
};
|
|
1221
|
+
};
|
|
1222
|
+
const buildGroupNode = (groupId) => {
|
|
1223
|
+
const group = graph.groups.find((g) => g.id === groupId);
|
|
1224
|
+
const pad = paddingForGroup(group);
|
|
1225
|
+
const children = [];
|
|
1226
|
+
for (const nodeId of group.nodeIds) {
|
|
1227
|
+
const m = measureMap.get(nodeId);
|
|
1228
|
+
if (!m) continue;
|
|
1229
|
+
assignedNodes.add(nodeId);
|
|
1230
|
+
children.push(buildLeaf(nodeId, m));
|
|
1231
|
+
}
|
|
1232
|
+
for (const child of groupsByParent.get(groupId) ?? []) children.push(buildGroupNode(child.id));
|
|
1233
|
+
return {
|
|
1234
|
+
id: `group:${group.id}`,
|
|
1235
|
+
children,
|
|
1236
|
+
labels: group.label ? [{
|
|
1237
|
+
id: `group:${group.id}:label`,
|
|
1238
|
+
text: group.label,
|
|
1239
|
+
width: 80,
|
|
1240
|
+
height: 18
|
|
1241
|
+
}] : void 0,
|
|
1242
|
+
layoutOptions: {
|
|
1243
|
+
"elk.padding": `[top=${pad.top},left=${pad.left},bottom=${pad.bottom},right=${pad.right}]`,
|
|
1244
|
+
"elk.spacing.nodeNode": String(Math.round(gap * .8)),
|
|
1245
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(Math.round(gap * .8)),
|
|
1246
|
+
"elk.layered.nodePlacement.strategy": nodePlacement,
|
|
1247
|
+
"elk.layered.nodePlacement.favorStraightEdges": "true",
|
|
1248
|
+
"elk.contentAlignment": "V_CENTER H_CENTER"
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
};
|
|
1252
|
+
const children = [];
|
|
1253
|
+
for (const top of groupsByParent.get(void 0) ?? []) children.push(buildGroupNode(top.id));
|
|
1254
|
+
for (const node of graph.nodes) {
|
|
1255
|
+
if (assignedNodes.has(node.id)) continue;
|
|
1256
|
+
const m = measureMap.get(node.id);
|
|
1257
|
+
if (!m) continue;
|
|
1258
|
+
children.push(buildLeaf(node.id, m));
|
|
1259
|
+
}
|
|
1260
|
+
const sourceRef = (edge) => portAssignment.edgeSourcePort.get(edge.id) ?? edge.from;
|
|
1261
|
+
const targetRef = (edge) => portAssignment.edgeTargetPort.get(edge.id) ?? edge.to;
|
|
1262
|
+
return {
|
|
1263
|
+
id: "root",
|
|
1264
|
+
layoutOptions: rootLayoutOptions,
|
|
1265
|
+
children,
|
|
1266
|
+
edges: graph.edges.map((e) => ({
|
|
1267
|
+
id: e.id,
|
|
1268
|
+
sources: [sourceRef(e)],
|
|
1269
|
+
targets: [targetRef(e)],
|
|
1270
|
+
layoutOptions: elkPriorityOptionsForEdge(e)
|
|
1271
|
+
}))
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
//#endregion
|
|
1275
|
+
//#region src/layout/elk/elk-engine.ts
|
|
1276
|
+
/** Shared elk engine — elkjs today (elk-rs drop-in when published). */
|
|
1277
|
+
const ElkCtor = ELK;
|
|
1278
|
+
let shared = null;
|
|
1279
|
+
function getElk() {
|
|
1280
|
+
if (!shared) shared = new ElkCtor({ defaultLayoutOptions: {
|
|
1281
|
+
"elk.algorithm": "layered",
|
|
1282
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
1283
|
+
"elk.hierarchyHandling": "INCLUDE_CHILDREN"
|
|
1284
|
+
} });
|
|
1285
|
+
return shared;
|
|
1286
|
+
}
|
|
1287
|
+
const ELK_LAYOUT_ALGORITHM = "elk-layered-v1";
|
|
1288
|
+
const ELK_ROUTER_ALGORITHM = "elk-orthogonal-v1";
|
|
1289
|
+
//#endregion
|
|
1290
|
+
//#region src/layout/attach-endpoints.ts
|
|
1291
|
+
const EPS$1 = .75;
|
|
1292
|
+
function toward(from, to) {
|
|
1293
|
+
return {
|
|
1294
|
+
x: to.x - from.x,
|
|
1295
|
+
y: to.y - from.y
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
function centerOf$1(bounds) {
|
|
1299
|
+
return {
|
|
1300
|
+
x: bounds.x + bounds.width / 2,
|
|
1301
|
+
y: bounds.y + bounds.height / 2
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
/** Prefer the dominant axis so attach rays stay H/V even when ELK left a diagonal stub. */
|
|
1305
|
+
function orthoDirection(v) {
|
|
1306
|
+
if (Math.abs(v.x) >= Math.abs(v.y)) return {
|
|
1307
|
+
x: Math.sign(v.x) || 1,
|
|
1308
|
+
y: 0
|
|
1309
|
+
};
|
|
1310
|
+
return {
|
|
1311
|
+
x: 0,
|
|
1312
|
+
y: Math.sign(v.y) || 1
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
function axisAligned(a, b) {
|
|
1316
|
+
return Math.abs(a.x - b.x) < EPS$1 || Math.abs(a.y - b.y) < EPS$1;
|
|
1317
|
+
}
|
|
1318
|
+
function applyOrthoAttach(points, endIndex, hit, direction) {
|
|
1319
|
+
const anchor = points[endIndex === 0 ? 1 : endIndex - 1];
|
|
1320
|
+
if (axisAligned(anchor, hit)) {
|
|
1321
|
+
points[endIndex] = hit;
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const corner = Math.abs(direction.y) >= Math.abs(direction.x) ? {
|
|
1325
|
+
x: anchor.x,
|
|
1326
|
+
y: hit.y
|
|
1327
|
+
} : {
|
|
1328
|
+
x: hit.x,
|
|
1329
|
+
y: anchor.y
|
|
1330
|
+
};
|
|
1331
|
+
points[endIndex] = hit;
|
|
1332
|
+
if (endIndex === 0) points.splice(1, 0, corner);
|
|
1333
|
+
else points.splice(endIndex, 0, corner);
|
|
1334
|
+
}
|
|
1335
|
+
/** Aim at the nearest facing side of the bounds so we do not flip H/V on shallow diagonals. */
|
|
1336
|
+
function directionOntoBounds(origin, bounds) {
|
|
1337
|
+
const L = bounds.x;
|
|
1338
|
+
const R = bounds.x + bounds.width;
|
|
1339
|
+
const T = bounds.y;
|
|
1340
|
+
const B = bounds.y + bounds.height;
|
|
1341
|
+
const inX = origin.x >= L - EPS$1 && origin.x <= R + EPS$1;
|
|
1342
|
+
const inY = origin.y >= T - EPS$1 && origin.y <= B + EPS$1;
|
|
1343
|
+
if (origin.y < T && inX) return {
|
|
1344
|
+
x: 0,
|
|
1345
|
+
y: 1
|
|
1346
|
+
};
|
|
1347
|
+
if (origin.y > B && inX) return {
|
|
1348
|
+
x: 0,
|
|
1349
|
+
y: -1
|
|
1350
|
+
};
|
|
1351
|
+
if (origin.x < L && inY) return {
|
|
1352
|
+
x: 1,
|
|
1353
|
+
y: 0
|
|
1354
|
+
};
|
|
1355
|
+
if (origin.x > R && inY) return {
|
|
1356
|
+
x: -1,
|
|
1357
|
+
y: 0
|
|
1358
|
+
};
|
|
1359
|
+
return orthoDirection(toward(origin, centerOf$1(bounds)));
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Replace path termini with shape-perimeter hits.
|
|
1363
|
+
* Attach rays are forced orthogonal; a corner is inserted when needed so the
|
|
1364
|
+
* final stub never becomes a diagonal shortcut.
|
|
1365
|
+
*/
|
|
1366
|
+
function snapEdgeEndpointsToGeometry(graph, nodes, edgePaths) {
|
|
1367
|
+
const nodeMap = new Map(nodes.map((n) => [n.nodeId, n]));
|
|
1368
|
+
const graphNodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
1369
|
+
return edgePaths.map((path) => {
|
|
1370
|
+
const edge = graph.edges.find((e) => e.id === path.edgeId);
|
|
1371
|
+
if (!edge || path.points.length < 2) return path;
|
|
1372
|
+
if (edge.fromColumn || edge.toColumn) return path;
|
|
1373
|
+
const points = path.points.map((p) => ({
|
|
1374
|
+
x: p.x,
|
|
1375
|
+
y: p.y
|
|
1376
|
+
}));
|
|
1377
|
+
const fromLaid = nodeMap.get(edge.from);
|
|
1378
|
+
const toLaid = nodeMap.get(edge.to);
|
|
1379
|
+
const fromNode = graphNodes.get(edge.from);
|
|
1380
|
+
const toNode = graphNodes.get(edge.to);
|
|
1381
|
+
if (fromLaid && fromNode) {
|
|
1382
|
+
const p1 = points[1];
|
|
1383
|
+
const direction = directionOntoBounds(p1, fromLaid.bounds);
|
|
1384
|
+
applyOrthoAttach(points, 0, attachPointOnPerimeter({
|
|
1385
|
+
shapeId: fromNode.shape,
|
|
1386
|
+
bounds: fromLaid.bounds,
|
|
1387
|
+
origin: p1,
|
|
1388
|
+
direction
|
|
1389
|
+
}), direction);
|
|
1390
|
+
}
|
|
1391
|
+
if (toLaid && toNode) {
|
|
1392
|
+
const prev = points[points.length - 2];
|
|
1393
|
+
const direction = directionOntoBounds(prev, toLaid.bounds);
|
|
1394
|
+
const hit = attachPointOnPerimeter({
|
|
1395
|
+
shapeId: toNode.shape,
|
|
1396
|
+
bounds: toLaid.bounds,
|
|
1397
|
+
origin: prev,
|
|
1398
|
+
direction
|
|
1399
|
+
});
|
|
1400
|
+
applyOrthoAttach(points, points.length - 1, hit, direction);
|
|
1401
|
+
}
|
|
1402
|
+
return {
|
|
1403
|
+
...path,
|
|
1404
|
+
points
|
|
1405
|
+
};
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
//#endregion
|
|
1409
|
+
//#region src/layout/region-arrange.ts
|
|
1410
|
+
const GAP_PRESETS = {
|
|
1411
|
+
compact: DENSITY_GAP.compact,
|
|
1412
|
+
normal: DENSITY_GAP.normal,
|
|
1413
|
+
spacious: DENSITY_GAP.spacious
|
|
1414
|
+
};
|
|
1415
|
+
function resolveArrangeGap(gap) {
|
|
1416
|
+
if (typeof gap === "number" && Number.isFinite(gap)) return Math.max(0, gap);
|
|
1417
|
+
if (typeof gap === "string" && GAP_PRESETS[gap] != null) return GAP_PRESETS[gap];
|
|
1418
|
+
return 72;
|
|
1419
|
+
}
|
|
1420
|
+
function trackCount(spec, fallback) {
|
|
1421
|
+
if (typeof spec === "number") return Math.max(1, Math.floor(spec));
|
|
1422
|
+
if (Array.isArray(spec)) return Math.max(1, spec.length);
|
|
1423
|
+
return Math.max(1, fallback);
|
|
1424
|
+
}
|
|
1425
|
+
function resolveTrackIndex(ref, spec, fallbackIndex, count) {
|
|
1426
|
+
if (typeof ref === "number" && ref >= 1) return Math.min(count - 1, Math.max(0, Math.floor(ref) - 1));
|
|
1427
|
+
if (typeof ref === "string" && Array.isArray(spec)) {
|
|
1428
|
+
const idx = spec.indexOf(ref);
|
|
1429
|
+
if (idx >= 0) return idx;
|
|
1430
|
+
}
|
|
1431
|
+
return Math.min(count - 1, Math.max(0, fallbackIndex));
|
|
1432
|
+
}
|
|
1433
|
+
function alignOffset(align, free) {
|
|
1434
|
+
if (free <= 0) return 0;
|
|
1435
|
+
switch (align) {
|
|
1436
|
+
case "end": return free;
|
|
1437
|
+
case "center": return free / 2;
|
|
1438
|
+
default: return 0;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Place sibling region cells into stack / row / grid tracks.
|
|
1443
|
+
* Pure geometry — no ELK. Stretch equalizes the cross-axis.
|
|
1444
|
+
*/
|
|
1445
|
+
function regionArrange(input) {
|
|
1446
|
+
const align = input.align ?? "stretch";
|
|
1447
|
+
const gap = resolveArrangeGap(input.gap);
|
|
1448
|
+
const originX = input.origin?.x ?? 0;
|
|
1449
|
+
const originY = input.origin?.y ?? 0;
|
|
1450
|
+
const cells = input.cells;
|
|
1451
|
+
if (cells.length === 0) return [];
|
|
1452
|
+
if (input.arrange === "stack") return arrangeStack(cells, align, gap, originX, originY);
|
|
1453
|
+
if (input.arrange === "row") return arrangeRow(cells, align, gap, originX, originY);
|
|
1454
|
+
return arrangeGrid(input, align, gap, originX, originY);
|
|
1455
|
+
}
|
|
1456
|
+
function arrangeStack(cells, align, gap, originX, originY) {
|
|
1457
|
+
const maxW = Math.max(...cells.map((c) => c.width), 0);
|
|
1458
|
+
let y = originY;
|
|
1459
|
+
const out = [];
|
|
1460
|
+
for (const cell of cells) {
|
|
1461
|
+
const width = align === "stretch" ? maxW : cell.width;
|
|
1462
|
+
const x = originX + alignOffset(align, maxW - width);
|
|
1463
|
+
out.push({
|
|
1464
|
+
groupId: cell.groupId,
|
|
1465
|
+
bounds: {
|
|
1466
|
+
x,
|
|
1467
|
+
y,
|
|
1468
|
+
width,
|
|
1469
|
+
height: cell.height
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
y += cell.height + gap;
|
|
1473
|
+
}
|
|
1474
|
+
return out;
|
|
1475
|
+
}
|
|
1476
|
+
function arrangeRow(cells, align, gap, originX, originY) {
|
|
1477
|
+
const maxH = Math.max(...cells.map((c) => c.height), 0);
|
|
1478
|
+
let x = originX;
|
|
1479
|
+
const out = [];
|
|
1480
|
+
for (const cell of cells) {
|
|
1481
|
+
const height = align === "stretch" ? maxH : cell.height;
|
|
1482
|
+
const y = originY + alignOffset(align, maxH - height);
|
|
1483
|
+
out.push({
|
|
1484
|
+
groupId: cell.groupId,
|
|
1485
|
+
bounds: {
|
|
1486
|
+
x,
|
|
1487
|
+
y,
|
|
1488
|
+
width: cell.width,
|
|
1489
|
+
height
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
x += cell.width + gap;
|
|
1493
|
+
}
|
|
1494
|
+
return out;
|
|
1495
|
+
}
|
|
1496
|
+
function arrangeGrid(input, align, gap, originX, originY) {
|
|
1497
|
+
const cells = input.cells;
|
|
1498
|
+
let autoCol = 0;
|
|
1499
|
+
let autoRow = 0;
|
|
1500
|
+
const colCountHint = trackCount(input.columns, 1);
|
|
1501
|
+
const placements = cells.map((cell) => {
|
|
1502
|
+
const colSpan = Math.max(1, cell.colSpan ?? 1);
|
|
1503
|
+
const rowSpan = Math.max(1, cell.rowSpan ?? 1);
|
|
1504
|
+
let col = resolveTrackIndex(cell.column, input.columns, autoCol, colCountHint);
|
|
1505
|
+
let row = resolveTrackIndex(cell.row, input.rows, autoRow, trackCount(input.rows, 1));
|
|
1506
|
+
if (cell.column == null && cell.row == null) {
|
|
1507
|
+
col = autoCol;
|
|
1508
|
+
row = autoRow;
|
|
1509
|
+
autoCol += colSpan;
|
|
1510
|
+
if (autoCol >= colCountHint) {
|
|
1511
|
+
autoCol = 0;
|
|
1512
|
+
autoRow += 1;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
cell,
|
|
1517
|
+
col,
|
|
1518
|
+
row,
|
|
1519
|
+
colSpan,
|
|
1520
|
+
rowSpan
|
|
1521
|
+
};
|
|
1522
|
+
});
|
|
1523
|
+
const maxCol = Math.max(colCountHint, ...placements.map((p) => p.col + p.colSpan));
|
|
1524
|
+
const maxRow = Math.max(trackCount(input.rows, 1), ...placements.map((p) => p.row + p.rowSpan));
|
|
1525
|
+
const colWidths = Array.from({ length: maxCol }, () => 0);
|
|
1526
|
+
const rowHeights = Array.from({ length: maxRow }, () => 0);
|
|
1527
|
+
for (const p of placements) {
|
|
1528
|
+
const perCol = p.cell.width / p.colSpan;
|
|
1529
|
+
const perRow = p.cell.height / p.rowSpan;
|
|
1530
|
+
for (let c = p.col; c < p.col + p.colSpan; c++) colWidths[c] = Math.max(colWidths[c], perCol);
|
|
1531
|
+
for (let r = p.row; r < p.row + p.rowSpan; r++) rowHeights[r] = Math.max(rowHeights[r], perRow);
|
|
1532
|
+
}
|
|
1533
|
+
const colX = [];
|
|
1534
|
+
let x = originX;
|
|
1535
|
+
for (let c = 0; c < maxCol; c++) {
|
|
1536
|
+
colX.push(x);
|
|
1537
|
+
x += colWidths[c] + (c < maxCol - 1 ? gap : 0);
|
|
1538
|
+
}
|
|
1539
|
+
const rowY = [];
|
|
1540
|
+
let y = originY;
|
|
1541
|
+
for (let r = 0; r < maxRow; r++) {
|
|
1542
|
+
rowY.push(y);
|
|
1543
|
+
y += rowHeights[r] + (r < maxRow - 1 ? gap : 0);
|
|
1544
|
+
}
|
|
1545
|
+
return placements.map((p) => {
|
|
1546
|
+
let width = 0;
|
|
1547
|
+
for (let c = p.col; c < p.col + p.colSpan; c++) {
|
|
1548
|
+
width += colWidths[c];
|
|
1549
|
+
if (c < p.col + p.colSpan - 1) width += gap;
|
|
1550
|
+
}
|
|
1551
|
+
let height = 0;
|
|
1552
|
+
for (let r = p.row; r < p.row + p.rowSpan; r++) {
|
|
1553
|
+
height += rowHeights[r];
|
|
1554
|
+
if (r < p.row + p.rowSpan - 1) height += gap;
|
|
1555
|
+
}
|
|
1556
|
+
const cellW = align === "stretch" ? width : p.cell.width;
|
|
1557
|
+
const cellH = align === "stretch" ? height : p.cell.height;
|
|
1558
|
+
const ox = alignOffset(align, width - cellW);
|
|
1559
|
+
const oy = alignOffset(align, height - cellH);
|
|
1560
|
+
return {
|
|
1561
|
+
groupId: p.cell.groupId,
|
|
1562
|
+
bounds: {
|
|
1563
|
+
x: colX[p.col] + ox,
|
|
1564
|
+
y: rowY[p.row] + oy,
|
|
1565
|
+
width: cellW,
|
|
1566
|
+
height: cellH
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
/** True when a group or diagram options request region arrangement. */
|
|
1572
|
+
function groupHasRegionArrange(group) {
|
|
1573
|
+
return group.arrange === "stack" || group.arrange === "row" || group.arrange === "grid";
|
|
1574
|
+
}
|
|
1575
|
+
//#endregion
|
|
1576
|
+
//#region src/layout/route-orthogonal-avoid.ts
|
|
1577
|
+
const EPS = .5;
|
|
1578
|
+
/** Keep corridors off node silhouettes so edges do not run along a face. */
|
|
1579
|
+
const SIDE_STANDOFF = 16;
|
|
1580
|
+
function inflate(b, pad) {
|
|
1581
|
+
return {
|
|
1582
|
+
x: b.x - pad,
|
|
1583
|
+
y: b.y - pad,
|
|
1584
|
+
width: b.width + pad * 2,
|
|
1585
|
+
height: b.height + pad * 2
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
function centerOf(b) {
|
|
1589
|
+
return {
|
|
1590
|
+
x: b.x + b.width / 2,
|
|
1591
|
+
y: b.y + b.height / 2
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
/** Orthogonal segment vs inflated axis-aligned rect (open interior). */
|
|
1595
|
+
function segmentHitsRect(a, b, box) {
|
|
1596
|
+
const minX = Math.min(a.x, b.x);
|
|
1597
|
+
const maxX = Math.max(a.x, b.x);
|
|
1598
|
+
const minY = Math.min(a.y, b.y);
|
|
1599
|
+
const maxY = Math.max(a.y, b.y);
|
|
1600
|
+
const L = box.x;
|
|
1601
|
+
const R = box.x + box.width;
|
|
1602
|
+
const T = box.y;
|
|
1603
|
+
const B = box.y + box.height;
|
|
1604
|
+
if (Math.abs(a.x - b.x) < EPS) {
|
|
1605
|
+
const x = a.x;
|
|
1606
|
+
if (x <= L + EPS || x >= R - EPS) return false;
|
|
1607
|
+
return maxY > T + EPS && minY < B - EPS;
|
|
1608
|
+
}
|
|
1609
|
+
if (Math.abs(a.y - b.y) < EPS) {
|
|
1610
|
+
const y = a.y;
|
|
1611
|
+
if (y <= T + EPS || y >= B - EPS) return false;
|
|
1612
|
+
return maxX > L + EPS && minX < R - EPS;
|
|
1613
|
+
}
|
|
1614
|
+
return false;
|
|
1615
|
+
}
|
|
1616
|
+
function rangesOverlap(a0, a1, b0, b1) {
|
|
1617
|
+
return Math.min(a0, a1) <= Math.max(b0, b1) && Math.max(a0, a1) >= Math.min(b0, b1);
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* True when an orthogonal segment runs along a box face (parallel hug).
|
|
1621
|
+
* Short stubs into an attach face are allowed; long slides along a side are not.
|
|
1622
|
+
*/
|
|
1623
|
+
function segmentHugsRect(a, b, box, standoff = SIDE_STANDOFF, allowShortStub = false) {
|
|
1624
|
+
const minX = Math.min(a.x, b.x);
|
|
1625
|
+
const maxX = Math.max(a.x, b.x);
|
|
1626
|
+
const minY = Math.min(a.y, b.y);
|
|
1627
|
+
const maxY = Math.max(a.y, b.y);
|
|
1628
|
+
const L = box.x;
|
|
1629
|
+
const R = box.x + box.width;
|
|
1630
|
+
const T = box.y;
|
|
1631
|
+
const B = box.y + box.height;
|
|
1632
|
+
const len = Math.abs(b.x - a.x) + Math.abs(b.y - a.y);
|
|
1633
|
+
if (Math.abs(a.x - b.x) < EPS) {
|
|
1634
|
+
const x = a.x;
|
|
1635
|
+
const alongLeft = Math.abs(x - L) <= standoff;
|
|
1636
|
+
const alongRight = Math.abs(x - R) <= standoff;
|
|
1637
|
+
if (!(alongLeft || alongRight)) return false;
|
|
1638
|
+
if (!rangesOverlap(minY, maxY, T, B)) return false;
|
|
1639
|
+
if (allowShortStub && len <= standoff * 1.5) return false;
|
|
1640
|
+
return Math.min(maxY, B) - Math.max(minY, T) > standoff * .5;
|
|
1641
|
+
}
|
|
1642
|
+
if (Math.abs(a.y - b.y) < EPS) {
|
|
1643
|
+
const y = a.y;
|
|
1644
|
+
const alongTop = Math.abs(y - T) <= standoff;
|
|
1645
|
+
const alongBottom = Math.abs(y - B) <= standoff;
|
|
1646
|
+
if (!(alongTop || alongBottom)) return false;
|
|
1647
|
+
if (!rangesOverlap(minX, maxX, L, R)) return false;
|
|
1648
|
+
if (allowShortStub && len <= standoff * 1.5) return false;
|
|
1649
|
+
return Math.min(maxX, R) - Math.max(minX, L) > standoff * .5;
|
|
1650
|
+
}
|
|
1651
|
+
return false;
|
|
1652
|
+
}
|
|
1653
|
+
function pathLength(points) {
|
|
1654
|
+
let len = 0;
|
|
1655
|
+
for (let i = 1; i < points.length; i++) {
|
|
1656
|
+
const a = points[i - 1];
|
|
1657
|
+
const b = points[i];
|
|
1658
|
+
len += Math.abs(b.x - a.x) + Math.abs(b.y - a.y);
|
|
1659
|
+
}
|
|
1660
|
+
return len;
|
|
1661
|
+
}
|
|
1662
|
+
function dedupe(points) {
|
|
1663
|
+
const out = [];
|
|
1664
|
+
for (const p of points) {
|
|
1665
|
+
const prev = out[out.length - 1];
|
|
1666
|
+
if (prev && Math.abs(prev.x - p.x) < EPS && Math.abs(prev.y - p.y) < EPS) continue;
|
|
1667
|
+
out.push(p);
|
|
1668
|
+
}
|
|
1669
|
+
return out;
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Collapse colinear bends, but keep the egress/ingress stubs so a later
|
|
1673
|
+
* corridor on the same line cannot erase the outward nudge and cut through
|
|
1674
|
+
* the source/target box.
|
|
1675
|
+
*
|
|
1676
|
+
* Final layout polish uses `collapseColinearPoints` in polish-edges.ts (no stub
|
|
1677
|
+
* preservation) after endpoints are frozen — different stage, different rule.
|
|
1678
|
+
*/
|
|
1679
|
+
function collapseColinear(points) {
|
|
1680
|
+
if (points.length < 3) return points;
|
|
1681
|
+
const out = [points[0]];
|
|
1682
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
1683
|
+
if (i === 1 || i === points.length - 2) {
|
|
1684
|
+
out.push(points[i]);
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
const a = out[out.length - 1];
|
|
1688
|
+
const b = points[i];
|
|
1689
|
+
const c = points[i + 1];
|
|
1690
|
+
const vertical = Math.abs(a.x - b.x) < EPS && Math.abs(b.x - c.x) < EPS;
|
|
1691
|
+
const horizontal = Math.abs(a.y - b.y) < EPS && Math.abs(b.y - c.y) < EPS;
|
|
1692
|
+
if (vertical || horizontal) continue;
|
|
1693
|
+
out.push(b);
|
|
1694
|
+
}
|
|
1695
|
+
out.push(points[points.length - 1]);
|
|
1696
|
+
return dedupe(out);
|
|
1697
|
+
}
|
|
1698
|
+
function attach(b, side, t) {
|
|
1699
|
+
const clamped = Math.min(.85, Math.max(.15, t));
|
|
1700
|
+
switch (side) {
|
|
1701
|
+
case "N": return {
|
|
1702
|
+
x: b.x + b.width * clamped,
|
|
1703
|
+
y: b.y
|
|
1704
|
+
};
|
|
1705
|
+
case "S": return {
|
|
1706
|
+
x: b.x + b.width * clamped,
|
|
1707
|
+
y: b.y + b.height
|
|
1708
|
+
};
|
|
1709
|
+
case "E": return {
|
|
1710
|
+
x: b.x + b.width,
|
|
1711
|
+
y: b.y + b.height * clamped
|
|
1712
|
+
};
|
|
1713
|
+
case "W": return {
|
|
1714
|
+
x: b.x,
|
|
1715
|
+
y: b.y + b.height * clamped
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/** Which face a point sits on (within EPS), if any. */
|
|
1720
|
+
function sideOfPoint(p, b) {
|
|
1721
|
+
if (Math.abs(p.y - b.y) < EPS && p.x >= b.x - EPS && p.x <= b.x + b.width + EPS) return "N";
|
|
1722
|
+
if (Math.abs(p.y - (b.y + b.height)) < EPS && p.x >= b.x - EPS && p.x <= b.x + b.width + EPS) return "S";
|
|
1723
|
+
if (Math.abs(p.x - (b.x + b.width)) < EPS && p.y >= b.y - EPS && p.y <= b.y + b.height + EPS) return "E";
|
|
1724
|
+
if (Math.abs(p.x - b.x) < EPS && p.y >= b.y - EPS && p.y <= b.y + b.height + EPS) return "W";
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Prefer the geometrically primary faces; heavily punish U-turns that attach on
|
|
1729
|
+
* the far side after wrapping under/around the target (common arranged-mode fail).
|
|
1730
|
+
*/
|
|
1731
|
+
function facePreferencePenalty(from, to, points, preferred) {
|
|
1732
|
+
const start = sideOfPoint(points[0], from);
|
|
1733
|
+
const end = sideOfPoint(points[points.length - 1], to);
|
|
1734
|
+
let penalty = 0;
|
|
1735
|
+
if (start && start !== preferred[0]) penalty += 2400;
|
|
1736
|
+
if (end && end !== preferred[1]) penalty += 9e3;
|
|
1737
|
+
const [fs, ts] = preferred;
|
|
1738
|
+
if (fs === "S" && ts === "N" && end === "S") penalty += 18e3;
|
|
1739
|
+
if (fs === "S" && ts === "N" && (end === "E" || end === "W")) penalty += 12e3;
|
|
1740
|
+
if (fs === "N" && ts === "S" && end === "N") penalty += 18e3;
|
|
1741
|
+
if (fs === "E" && ts === "W" && end === "E") penalty += 18e3;
|
|
1742
|
+
if (fs === "W" && ts === "E" && end === "W") penalty += 18e3;
|
|
1743
|
+
return penalty;
|
|
1744
|
+
}
|
|
1745
|
+
/** Point outside the attach face — corridors route through here, not on the silhouette. */
|
|
1746
|
+
function egress(b, side, t, standoff) {
|
|
1747
|
+
const p = attach(b, side, t);
|
|
1748
|
+
switch (side) {
|
|
1749
|
+
case "E": return {
|
|
1750
|
+
x: p.x + standoff,
|
|
1751
|
+
y: p.y
|
|
1752
|
+
};
|
|
1753
|
+
case "W": return {
|
|
1754
|
+
x: p.x - standoff,
|
|
1755
|
+
y: p.y
|
|
1756
|
+
};
|
|
1757
|
+
case "S": return {
|
|
1758
|
+
x: p.x,
|
|
1759
|
+
y: p.y + standoff
|
|
1760
|
+
};
|
|
1761
|
+
case "N": return {
|
|
1762
|
+
x: p.x,
|
|
1763
|
+
y: p.y - standoff
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
function sidePairs(from, to) {
|
|
1768
|
+
const primary = preferredSidePair(from, to);
|
|
1769
|
+
const eastWest = to.x - (from.x + from.width);
|
|
1770
|
+
const westEast = from.x - (to.x + to.width);
|
|
1771
|
+
const southNorth = to.y - (from.y + from.height);
|
|
1772
|
+
const northSouth = from.y - (to.y + to.height);
|
|
1773
|
+
return [
|
|
1774
|
+
primary,
|
|
1775
|
+
primary[0] === "E" || primary[0] === "W" ? southNorth >= northSouth ? ["S", "N"] : ["N", "S"] : eastWest >= westEast ? ["E", "W"] : ["W", "E"],
|
|
1776
|
+
["E", "W"],
|
|
1777
|
+
["W", "E"],
|
|
1778
|
+
["S", "N"],
|
|
1779
|
+
["N", "S"]
|
|
1780
|
+
];
|
|
1781
|
+
}
|
|
1782
|
+
/** Primary attach faces — shared with arranged fan-slot grouping. */
|
|
1783
|
+
function preferredSidePair(from, to) {
|
|
1784
|
+
const eastWest = to.x - (from.x + from.width);
|
|
1785
|
+
const westEast = from.x - (to.x + to.width);
|
|
1786
|
+
const southNorth = to.y - (from.y + from.height);
|
|
1787
|
+
const northSouth = from.y - (to.y + to.height);
|
|
1788
|
+
const hClear = Math.max(eastWest, westEast);
|
|
1789
|
+
const vClear = Math.max(southNorth, northSouth);
|
|
1790
|
+
const separatedH = hClear > 8;
|
|
1791
|
+
const separatedV = vClear > 8;
|
|
1792
|
+
if (separatedH && (!separatedV || hClear >= vClear * .35 || hClear > 48)) return eastWest >= westEast ? ["E", "W"] : ["W", "E"];
|
|
1793
|
+
if (separatedV) return southNorth >= northSouth ? ["S", "N"] : ["N", "S"];
|
|
1794
|
+
const fc = centerOf(from);
|
|
1795
|
+
const tc = centerOf(to);
|
|
1796
|
+
const dx = tc.x - fc.x;
|
|
1797
|
+
const dy = tc.y - fc.y;
|
|
1798
|
+
return Math.abs(dx) >= Math.abs(dy) ? dx >= 0 ? ["E", "W"] : ["W", "E"] : dy >= 0 ? ["S", "N"] : ["N", "S"];
|
|
1799
|
+
}
|
|
1800
|
+
/** Clearance between facing sides; null when the pair does not face. */
|
|
1801
|
+
function facingClearance(from, to, fs, ts) {
|
|
1802
|
+
if (fs === "S" && ts === "N") return to.y - (from.y + from.height);
|
|
1803
|
+
if (fs === "N" && ts === "S") return from.y - (to.y + to.height);
|
|
1804
|
+
if (fs === "E" && ts === "W") return to.x - (from.x + from.width);
|
|
1805
|
+
if (fs === "W" && ts === "E") return from.x - (to.x + to.width);
|
|
1806
|
+
return null;
|
|
1807
|
+
}
|
|
1808
|
+
/** True when the boxes overlap on the axis orthogonal to the facing sides. */
|
|
1809
|
+
function facesOverlap(from, to, fs) {
|
|
1810
|
+
if (fs === "S" || fs === "N") return rangesOverlap(from.x, from.x + from.width, to.x, to.x + to.width);
|
|
1811
|
+
return rangesOverlap(from.y, from.y + from.height, to.y, to.y + to.height);
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Pack/stack cells leave ~16px gaps, but the default egress turn is ~36px.
|
|
1815
|
+
* When neighbors face across a tight clear gap, route in the interstitial
|
|
1816
|
+
* channel instead of overshooting into the target and escaping around the hull.
|
|
1817
|
+
*/
|
|
1818
|
+
function tryTightNeighborRoute(from, to, obstacles, pad, tFrom, tTo, turn) {
|
|
1819
|
+
const [fs, ts] = sidePairs(from, to)[0];
|
|
1820
|
+
const gap = facingClearance(from, to, fs, ts);
|
|
1821
|
+
if (gap == null || gap <= 0 || gap >= turn * 2) return null;
|
|
1822
|
+
if (!facesOverlap(from, to, fs)) return null;
|
|
1823
|
+
const aAttach = attach(from, fs, tFrom);
|
|
1824
|
+
const bAttach = attach(to, ts, tTo);
|
|
1825
|
+
const half = gap / 2;
|
|
1826
|
+
let mid;
|
|
1827
|
+
if (fs === "S" || fs === "N") {
|
|
1828
|
+
const y = fs === "S" ? from.y + from.height + half : from.y - half;
|
|
1829
|
+
mid = [{
|
|
1830
|
+
x: aAttach.x,
|
|
1831
|
+
y
|
|
1832
|
+
}, {
|
|
1833
|
+
x: bAttach.x,
|
|
1834
|
+
y
|
|
1835
|
+
}];
|
|
1836
|
+
} else {
|
|
1837
|
+
const x = fs === "E" ? from.x + from.width + half : from.x - half;
|
|
1838
|
+
mid = [{
|
|
1839
|
+
x,
|
|
1840
|
+
y: aAttach.y
|
|
1841
|
+
}, {
|
|
1842
|
+
x,
|
|
1843
|
+
y: bAttach.y
|
|
1844
|
+
}];
|
|
1845
|
+
}
|
|
1846
|
+
const points = collapseColinear(dedupe([
|
|
1847
|
+
aAttach,
|
|
1848
|
+
...mid,
|
|
1849
|
+
bAttach
|
|
1850
|
+
]));
|
|
1851
|
+
const channelPad = Math.min(pad, Math.max(0, gap / 4 - 1));
|
|
1852
|
+
if (countHits(points, obstacles.map((b) => inflate(b, channelPad)), [from, to]) > 0) return null;
|
|
1853
|
+
return points;
|
|
1854
|
+
}
|
|
1855
|
+
function turnForPair(baseTurn, gap) {
|
|
1856
|
+
if (gap == null || gap <= 0) return baseTurn;
|
|
1857
|
+
return Math.min(baseTurn, Math.max(2, gap / 2 - 1));
|
|
1858
|
+
}
|
|
1859
|
+
function hvPath(a, b) {
|
|
1860
|
+
if (Math.abs(a.y - b.y) < EPS || Math.abs(a.x - b.x) < EPS) return [a, b];
|
|
1861
|
+
return [
|
|
1862
|
+
a,
|
|
1863
|
+
{
|
|
1864
|
+
x: b.x,
|
|
1865
|
+
y: a.y
|
|
1866
|
+
},
|
|
1867
|
+
b
|
|
1868
|
+
];
|
|
1869
|
+
}
|
|
1870
|
+
function vhPath(a, b) {
|
|
1871
|
+
if (Math.abs(a.y - b.y) < EPS || Math.abs(a.x - b.x) < EPS) return [a, b];
|
|
1872
|
+
return [
|
|
1873
|
+
a,
|
|
1874
|
+
{
|
|
1875
|
+
x: a.x,
|
|
1876
|
+
y: b.y
|
|
1877
|
+
},
|
|
1878
|
+
b
|
|
1879
|
+
];
|
|
1880
|
+
}
|
|
1881
|
+
function unionBounds(rects) {
|
|
1882
|
+
if (rects.length === 0) return null;
|
|
1883
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1884
|
+
for (const b of rects) {
|
|
1885
|
+
minX = Math.min(minX, b.x);
|
|
1886
|
+
minY = Math.min(minY, b.y);
|
|
1887
|
+
maxX = Math.max(maxX, b.x + b.width);
|
|
1888
|
+
maxY = Math.max(maxY, b.y + b.height);
|
|
1889
|
+
}
|
|
1890
|
+
return {
|
|
1891
|
+
x: minX,
|
|
1892
|
+
y: minY,
|
|
1893
|
+
width: maxX - minX,
|
|
1894
|
+
height: maxY - minY
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Rectilinear route between two node boxes that prefers corridors clear of
|
|
1899
|
+
* other node footprints. Candidate channels only (not a grid/A* router).
|
|
1900
|
+
*/
|
|
1901
|
+
function routeOrthogonalAvoiding(from, to, obstacles, pad, tFrom = .5, tTo = .5) {
|
|
1902
|
+
const pads = [
|
|
1903
|
+
pad,
|
|
1904
|
+
Math.min(pad, 12),
|
|
1905
|
+
6,
|
|
1906
|
+
2
|
|
1907
|
+
].filter((p, i, arr) => arr.indexOf(p) === i);
|
|
1908
|
+
let fallback = null;
|
|
1909
|
+
for (const tryPad of pads) {
|
|
1910
|
+
const result = routeWithPad(from, to, obstacles, tryPad, tFrom, tTo);
|
|
1911
|
+
if (result.clear) return result.points;
|
|
1912
|
+
fallback ??= result.points;
|
|
1913
|
+
}
|
|
1914
|
+
return fallback ?? [centerOf(from), centerOf(to)];
|
|
1915
|
+
}
|
|
1916
|
+
function routeWithPad(from, to, obstacles, pad, tFrom, tTo) {
|
|
1917
|
+
const turn = Math.max(Math.max(SIDE_STANDOFF, pad) * 2.25, pad + 14);
|
|
1918
|
+
const tight = tryTightNeighborRoute(from, to, obstacles, pad, tFrom, tTo, turn);
|
|
1919
|
+
if (tight) return {
|
|
1920
|
+
points: tight,
|
|
1921
|
+
clear: true
|
|
1922
|
+
};
|
|
1923
|
+
const hugReach = Math.max(SIDE_STANDOFF, turn * .75);
|
|
1924
|
+
const inflated = obstacles.map((b) => inflate(b, pad));
|
|
1925
|
+
const endpointBoxes = [from, to];
|
|
1926
|
+
const candidates = [];
|
|
1927
|
+
const pairs = sidePairs(from, to);
|
|
1928
|
+
const preferred = pairs[0];
|
|
1929
|
+
for (let rank = 0; rank < pairs.length; rank++) {
|
|
1930
|
+
const [fs, ts] = pairs[rank];
|
|
1931
|
+
const pairTurn = turnForPair(turn, facingClearance(from, to, fs, ts));
|
|
1932
|
+
const aAttach = attach(from, fs, tFrom);
|
|
1933
|
+
const bAttach = attach(to, ts, tTo);
|
|
1934
|
+
const a = egress(from, fs, tFrom, pairTurn);
|
|
1935
|
+
const b = egress(to, ts, tTo, pairTurn);
|
|
1936
|
+
for (const mid of [hvPath(a, b), vhPath(a, b)]) candidates.push({
|
|
1937
|
+
raw: [
|
|
1938
|
+
aAttach,
|
|
1939
|
+
...mid,
|
|
1940
|
+
bAttach
|
|
1941
|
+
],
|
|
1942
|
+
rank
|
|
1943
|
+
});
|
|
1944
|
+
const midX = (a.x + b.x) / 2;
|
|
1945
|
+
const midY = (a.y + b.y) / 2;
|
|
1946
|
+
candidates.push({
|
|
1947
|
+
raw: [
|
|
1948
|
+
aAttach,
|
|
1949
|
+
a,
|
|
1950
|
+
{
|
|
1951
|
+
x: midX,
|
|
1952
|
+
y: a.y
|
|
1953
|
+
},
|
|
1954
|
+
{
|
|
1955
|
+
x: midX,
|
|
1956
|
+
y: b.y
|
|
1957
|
+
},
|
|
1958
|
+
b,
|
|
1959
|
+
bAttach
|
|
1960
|
+
],
|
|
1961
|
+
rank
|
|
1962
|
+
});
|
|
1963
|
+
candidates.push({
|
|
1964
|
+
raw: [
|
|
1965
|
+
aAttach,
|
|
1966
|
+
a,
|
|
1967
|
+
{
|
|
1968
|
+
x: a.x,
|
|
1969
|
+
y: midY
|
|
1970
|
+
},
|
|
1971
|
+
{
|
|
1972
|
+
x: b.x,
|
|
1973
|
+
y: midY
|
|
1974
|
+
},
|
|
1975
|
+
b,
|
|
1976
|
+
bAttach
|
|
1977
|
+
],
|
|
1978
|
+
rank
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
pushFacingChannelDetours(candidates, from, to, inflated, pad, tFrom, tTo, preferred);
|
|
1982
|
+
const hull = unionBounds([
|
|
1983
|
+
...localObstacles(from, to, inflated),
|
|
1984
|
+
inflate(from, pad),
|
|
1985
|
+
inflate(to, pad)
|
|
1986
|
+
]);
|
|
1987
|
+
if (hull) {
|
|
1988
|
+
const clear = Math.max(pad, turn);
|
|
1989
|
+
const left = hull.x - clear;
|
|
1990
|
+
const right = hull.x + hull.width + clear;
|
|
1991
|
+
const top = hull.y - clear;
|
|
1992
|
+
const bottom = hull.y + hull.height + clear;
|
|
1993
|
+
const entrySides = [preferred[1], ...[
|
|
1994
|
+
"N",
|
|
1995
|
+
"S",
|
|
1996
|
+
"E",
|
|
1997
|
+
"W"
|
|
1998
|
+
].filter((s) => s !== preferred[1])];
|
|
1999
|
+
for (const ts of entrySides) pushRailEscapes(candidates, from, tFrom, turn, pad, left, right, top, bottom, attach(to, ts, tTo), ts === preferred[1] ? 2 : 5);
|
|
2000
|
+
}
|
|
2001
|
+
let bestClear = null;
|
|
2002
|
+
let bestClearScore = Infinity;
|
|
2003
|
+
let bestAny = candidates[0]?.raw ?? [centerOf(from), centerOf(to)];
|
|
2004
|
+
let bestAnyScore = Infinity;
|
|
2005
|
+
for (const cand of candidates) {
|
|
2006
|
+
const points = collapseColinear(dedupe(cand.raw));
|
|
2007
|
+
if (points.length < 2) continue;
|
|
2008
|
+
const len = pathLength(points);
|
|
2009
|
+
const hits = countHits(points, inflated, endpointBoxes);
|
|
2010
|
+
const hugs = countHugs(points, endpointBoxes, hugReach) + countHugs(points, obstacles, SIDE_STANDOFF);
|
|
2011
|
+
const facePenalty = facePreferencePenalty(from, to, points, preferred);
|
|
2012
|
+
const score = hits * 1e5 + hugs * 140 + cand.rank * 320 + facePenalty + len;
|
|
2013
|
+
if (score < bestAnyScore) {
|
|
2014
|
+
bestAnyScore = score;
|
|
2015
|
+
bestAny = points;
|
|
2016
|
+
}
|
|
2017
|
+
if (hits === 0 && score < bestClearScore) {
|
|
2018
|
+
bestClearScore = score;
|
|
2019
|
+
bestClear = points;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return {
|
|
2023
|
+
points: bestClear ?? bestAny,
|
|
2024
|
+
clear: bestClear != null
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Short detours that skim above/below (or beside) blockers in the facing
|
|
2029
|
+
* channel — avoids full-hull perimeter tours when only a pack sibling blocks.
|
|
2030
|
+
*/
|
|
2031
|
+
function pushFacingChannelDetours(candidates, from, to, inflated, pad, tFrom, tTo, preferred) {
|
|
2032
|
+
const [fs, ts] = preferred;
|
|
2033
|
+
const clear = Math.max(pad, SIDE_STANDOFF);
|
|
2034
|
+
const bAttach = attach(to, ts, tTo);
|
|
2035
|
+
const b = egress(to, ts, tTo, clear);
|
|
2036
|
+
if (fs === "E" && ts === "W" || fs === "W" && ts === "E") {
|
|
2037
|
+
const x0 = fs === "E" ? from.x + from.width : to.x + to.width;
|
|
2038
|
+
const x1 = fs === "E" ? to.x : from.x;
|
|
2039
|
+
if (x1 <= x0 + 1) return;
|
|
2040
|
+
const blockers = inflated.filter((box) => box.x + box.width > x0 - 1 && box.x < x1 - 1 && (rangesOverlap(box.y, box.y + box.height, from.y, from.y + from.height) || rangesOverlap(box.y, box.y + box.height, to.y, to.y + to.height)));
|
|
2041
|
+
if (blockers.length === 0) return;
|
|
2042
|
+
const topLane = Math.min(...blockers.map((box) => box.y)) - clear;
|
|
2043
|
+
const bottomLane = Math.max(...blockers.map((box) => box.y + box.height)) + clear;
|
|
2044
|
+
const aN = attach(from, "N", tFrom);
|
|
2045
|
+
const aS = attach(from, "S", tFrom);
|
|
2046
|
+
candidates.push({
|
|
2047
|
+
raw: [
|
|
2048
|
+
aN,
|
|
2049
|
+
{
|
|
2050
|
+
x: aN.x,
|
|
2051
|
+
y: topLane
|
|
2052
|
+
},
|
|
2053
|
+
{
|
|
2054
|
+
x: b.x,
|
|
2055
|
+
y: topLane
|
|
2056
|
+
},
|
|
2057
|
+
b,
|
|
2058
|
+
bAttach
|
|
2059
|
+
],
|
|
2060
|
+
rank: 1
|
|
2061
|
+
});
|
|
2062
|
+
candidates.push({
|
|
2063
|
+
raw: [
|
|
2064
|
+
aS,
|
|
2065
|
+
{
|
|
2066
|
+
x: aS.x,
|
|
2067
|
+
y: bottomLane
|
|
2068
|
+
},
|
|
2069
|
+
{
|
|
2070
|
+
x: b.x,
|
|
2071
|
+
y: bottomLane
|
|
2072
|
+
},
|
|
2073
|
+
b,
|
|
2074
|
+
bAttach
|
|
2075
|
+
],
|
|
2076
|
+
rank: 1
|
|
2077
|
+
});
|
|
2078
|
+
const aF = attach(from, fs, tFrom);
|
|
2079
|
+
const jogX = fs === "E" ? Math.min(aF.x + clear, (x0 + x1) / 2) : Math.max(aF.x - clear, (x0 + x1) / 2);
|
|
2080
|
+
candidates.push({
|
|
2081
|
+
raw: [
|
|
2082
|
+
aF,
|
|
2083
|
+
{
|
|
2084
|
+
x: jogX,
|
|
2085
|
+
y: aF.y
|
|
2086
|
+
},
|
|
2087
|
+
{
|
|
2088
|
+
x: jogX,
|
|
2089
|
+
y: topLane
|
|
2090
|
+
},
|
|
2091
|
+
{
|
|
2092
|
+
x: b.x,
|
|
2093
|
+
y: topLane
|
|
2094
|
+
},
|
|
2095
|
+
b,
|
|
2096
|
+
bAttach
|
|
2097
|
+
],
|
|
2098
|
+
rank: 0
|
|
2099
|
+
});
|
|
2100
|
+
candidates.push({
|
|
2101
|
+
raw: [
|
|
2102
|
+
aF,
|
|
2103
|
+
{
|
|
2104
|
+
x: jogX,
|
|
2105
|
+
y: aF.y
|
|
2106
|
+
},
|
|
2107
|
+
{
|
|
2108
|
+
x: jogX,
|
|
2109
|
+
y: bottomLane
|
|
2110
|
+
},
|
|
2111
|
+
{
|
|
2112
|
+
x: b.x,
|
|
2113
|
+
y: bottomLane
|
|
2114
|
+
},
|
|
2115
|
+
b,
|
|
2116
|
+
bAttach
|
|
2117
|
+
],
|
|
2118
|
+
rank: 0
|
|
2119
|
+
});
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
if (fs === "S" && ts === "N" || fs === "N" && ts === "S") {
|
|
2123
|
+
const y0 = fs === "S" ? from.y + from.height : to.y + to.height;
|
|
2124
|
+
const y1 = fs === "S" ? to.y : from.y;
|
|
2125
|
+
if (y1 <= y0 + 1) return;
|
|
2126
|
+
const blockers = inflated.filter((box) => box.y + box.height > y0 - 1 && box.y < y1 - 1 && (rangesOverlap(box.x, box.x + box.width, from.x, from.x + from.width) || rangesOverlap(box.x, box.x + box.width, to.x, to.x + to.width)));
|
|
2127
|
+
if (blockers.length === 0) return;
|
|
2128
|
+
const leftLane = Math.min(...blockers.map((box) => box.x)) - clear;
|
|
2129
|
+
const rightLane = Math.max(...blockers.map((box) => box.x + box.width)) + clear;
|
|
2130
|
+
const aW = attach(from, "W", tFrom);
|
|
2131
|
+
const aE = attach(from, "E", tFrom);
|
|
2132
|
+
candidates.push({
|
|
2133
|
+
raw: [
|
|
2134
|
+
aW,
|
|
2135
|
+
{
|
|
2136
|
+
x: leftLane,
|
|
2137
|
+
y: aW.y
|
|
2138
|
+
},
|
|
2139
|
+
{
|
|
2140
|
+
x: leftLane,
|
|
2141
|
+
y: b.y
|
|
2142
|
+
},
|
|
2143
|
+
b,
|
|
2144
|
+
bAttach
|
|
2145
|
+
],
|
|
2146
|
+
rank: 1
|
|
2147
|
+
});
|
|
2148
|
+
candidates.push({
|
|
2149
|
+
raw: [
|
|
2150
|
+
aE,
|
|
2151
|
+
{
|
|
2152
|
+
x: rightLane,
|
|
2153
|
+
y: aE.y
|
|
2154
|
+
},
|
|
2155
|
+
{
|
|
2156
|
+
x: rightLane,
|
|
2157
|
+
y: b.y
|
|
2158
|
+
},
|
|
2159
|
+
b,
|
|
2160
|
+
bAttach
|
|
2161
|
+
],
|
|
2162
|
+
rank: 1
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Obstacles near the endpoints — generous AABB so pack siblings beside the
|
|
2168
|
+
* straight span still shape the local hull (and its outside rails).
|
|
2169
|
+
*/
|
|
2170
|
+
function localObstacles(from, to, inflated, margin = 160) {
|
|
2171
|
+
const x0 = Math.min(from.x, to.x) - margin;
|
|
2172
|
+
const y0 = Math.min(from.y, to.y) - margin;
|
|
2173
|
+
const x1 = Math.max(from.x + from.width, to.x + to.width) + margin;
|
|
2174
|
+
const y1 = Math.max(from.y + from.height, to.y + to.height) + margin;
|
|
2175
|
+
return inflated.filter((box) => !(box.x + box.width < x0 || box.x > x1 || box.y + box.height < y0 || box.y > y1));
|
|
2176
|
+
}
|
|
2177
|
+
/** Ortho routes that ride the outside of a local hull without mid-pack cuts. */
|
|
2178
|
+
function pushRailEscapes(candidates, from, tFrom, turn, pad, left, right, top, bottom, bAttach, rank = 3) {
|
|
2179
|
+
const aN = attach(from, "N", tFrom);
|
|
2180
|
+
const aS = attach(from, "S", tFrom);
|
|
2181
|
+
const aE = attach(from, "E", tFrom);
|
|
2182
|
+
const aW = attach(from, "W", tFrom);
|
|
2183
|
+
const nTurn = Math.min(turn, Math.max(pad, aN.y - top));
|
|
2184
|
+
const sTurn = Math.min(turn, Math.max(pad, bottom - aS.y));
|
|
2185
|
+
const eTurn = Math.min(turn, Math.max(pad, right - aE.x));
|
|
2186
|
+
const wTurn = Math.min(turn, Math.max(pad, aW.x - left));
|
|
2187
|
+
const n = egress(from, "N", tFrom, nTurn);
|
|
2188
|
+
const s = egress(from, "S", tFrom, sTurn);
|
|
2189
|
+
const e = egress(from, "E", tFrom, eTurn);
|
|
2190
|
+
const w = egress(from, "W", tFrom, wTurn);
|
|
2191
|
+
candidates.push({
|
|
2192
|
+
raw: [
|
|
2193
|
+
aE,
|
|
2194
|
+
e,
|
|
2195
|
+
{
|
|
2196
|
+
x: right,
|
|
2197
|
+
y: e.y
|
|
2198
|
+
},
|
|
2199
|
+
{
|
|
2200
|
+
x: right,
|
|
2201
|
+
y: top
|
|
2202
|
+
},
|
|
2203
|
+
{
|
|
2204
|
+
x: bAttach.x,
|
|
2205
|
+
y: top
|
|
2206
|
+
},
|
|
2207
|
+
bAttach
|
|
2208
|
+
],
|
|
2209
|
+
rank
|
|
2210
|
+
});
|
|
2211
|
+
candidates.push({
|
|
2212
|
+
raw: [
|
|
2213
|
+
aE,
|
|
2214
|
+
e,
|
|
2215
|
+
{
|
|
2216
|
+
x: right,
|
|
2217
|
+
y: e.y
|
|
2218
|
+
},
|
|
2219
|
+
{
|
|
2220
|
+
x: right,
|
|
2221
|
+
y: bottom
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
x: bAttach.x,
|
|
2225
|
+
y: bottom
|
|
2226
|
+
},
|
|
2227
|
+
bAttach
|
|
2228
|
+
],
|
|
2229
|
+
rank: rank + 1
|
|
2230
|
+
});
|
|
2231
|
+
candidates.push({
|
|
2232
|
+
raw: [
|
|
2233
|
+
aW,
|
|
2234
|
+
w,
|
|
2235
|
+
{
|
|
2236
|
+
x: left,
|
|
2237
|
+
y: w.y
|
|
2238
|
+
},
|
|
2239
|
+
{
|
|
2240
|
+
x: left,
|
|
2241
|
+
y: top
|
|
2242
|
+
},
|
|
2243
|
+
{
|
|
2244
|
+
x: bAttach.x,
|
|
2245
|
+
y: top
|
|
2246
|
+
},
|
|
2247
|
+
bAttach
|
|
2248
|
+
],
|
|
2249
|
+
rank
|
|
2250
|
+
});
|
|
2251
|
+
candidates.push({
|
|
2252
|
+
raw: [
|
|
2253
|
+
aW,
|
|
2254
|
+
w,
|
|
2255
|
+
{
|
|
2256
|
+
x: left,
|
|
2257
|
+
y: w.y
|
|
2258
|
+
},
|
|
2259
|
+
{
|
|
2260
|
+
x: left,
|
|
2261
|
+
y: bottom
|
|
2262
|
+
},
|
|
2263
|
+
{
|
|
2264
|
+
x: bAttach.x,
|
|
2265
|
+
y: bottom
|
|
2266
|
+
},
|
|
2267
|
+
bAttach
|
|
2268
|
+
],
|
|
2269
|
+
rank: rank + 1
|
|
2270
|
+
});
|
|
2271
|
+
candidates.push({
|
|
2272
|
+
raw: [
|
|
2273
|
+
aN,
|
|
2274
|
+
n,
|
|
2275
|
+
{
|
|
2276
|
+
x: n.x,
|
|
2277
|
+
y: top
|
|
2278
|
+
},
|
|
2279
|
+
{
|
|
2280
|
+
x: left,
|
|
2281
|
+
y: top
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
x: left,
|
|
2285
|
+
y: bAttach.y
|
|
2286
|
+
},
|
|
2287
|
+
bAttach
|
|
2288
|
+
],
|
|
2289
|
+
rank
|
|
2290
|
+
});
|
|
2291
|
+
candidates.push({
|
|
2292
|
+
raw: [
|
|
2293
|
+
aN,
|
|
2294
|
+
n,
|
|
2295
|
+
{
|
|
2296
|
+
x: n.x,
|
|
2297
|
+
y: top
|
|
2298
|
+
},
|
|
2299
|
+
{
|
|
2300
|
+
x: right,
|
|
2301
|
+
y: top
|
|
2302
|
+
},
|
|
2303
|
+
{
|
|
2304
|
+
x: right,
|
|
2305
|
+
y: bAttach.y
|
|
2306
|
+
},
|
|
2307
|
+
bAttach
|
|
2308
|
+
],
|
|
2309
|
+
rank
|
|
2310
|
+
});
|
|
2311
|
+
candidates.push({
|
|
2312
|
+
raw: [
|
|
2313
|
+
aN,
|
|
2314
|
+
n,
|
|
2315
|
+
{
|
|
2316
|
+
x: n.x,
|
|
2317
|
+
y: top
|
|
2318
|
+
},
|
|
2319
|
+
{
|
|
2320
|
+
x: bAttach.x,
|
|
2321
|
+
y: top
|
|
2322
|
+
},
|
|
2323
|
+
bAttach
|
|
2324
|
+
],
|
|
2325
|
+
rank: rank + 1
|
|
2326
|
+
});
|
|
2327
|
+
candidates.push({
|
|
2328
|
+
raw: [
|
|
2329
|
+
aS,
|
|
2330
|
+
s,
|
|
2331
|
+
{
|
|
2332
|
+
x: s.x,
|
|
2333
|
+
y: bottom
|
|
2334
|
+
},
|
|
2335
|
+
{
|
|
2336
|
+
x: left,
|
|
2337
|
+
y: bottom
|
|
2338
|
+
},
|
|
2339
|
+
{
|
|
2340
|
+
x: left,
|
|
2341
|
+
y: bAttach.y
|
|
2342
|
+
},
|
|
2343
|
+
bAttach
|
|
2344
|
+
],
|
|
2345
|
+
rank
|
|
2346
|
+
});
|
|
2347
|
+
candidates.push({
|
|
2348
|
+
raw: [
|
|
2349
|
+
aS,
|
|
2350
|
+
s,
|
|
2351
|
+
{
|
|
2352
|
+
x: s.x,
|
|
2353
|
+
y: bottom
|
|
2354
|
+
},
|
|
2355
|
+
{
|
|
2356
|
+
x: right,
|
|
2357
|
+
y: bottom
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
x: right,
|
|
2361
|
+
y: bAttach.y
|
|
2362
|
+
},
|
|
2363
|
+
bAttach
|
|
2364
|
+
],
|
|
2365
|
+
rank
|
|
2366
|
+
});
|
|
2367
|
+
candidates.push({
|
|
2368
|
+
raw: [
|
|
2369
|
+
aS,
|
|
2370
|
+
s,
|
|
2371
|
+
{
|
|
2372
|
+
x: s.x,
|
|
2373
|
+
y: bottom
|
|
2374
|
+
},
|
|
2375
|
+
{
|
|
2376
|
+
x: bAttach.x,
|
|
2377
|
+
y: bottom
|
|
2378
|
+
},
|
|
2379
|
+
bAttach
|
|
2380
|
+
],
|
|
2381
|
+
rank: rank + 1
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
function countHits(points, obstacles, endpoints) {
|
|
2385
|
+
let n = 0;
|
|
2386
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
2387
|
+
const isStub = i === 0 || i === points.length - 2;
|
|
2388
|
+
for (const box of obstacles) if (segmentHitsRect(points[i], points[i + 1], box)) n += 1;
|
|
2389
|
+
if (!isStub) {
|
|
2390
|
+
for (const box of endpoints) if (segmentHitsRect(points[i], points[i + 1], box)) n += 1;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return n;
|
|
2394
|
+
}
|
|
2395
|
+
function countHugs(points, boxes, reach) {
|
|
2396
|
+
let n = 0;
|
|
2397
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
2398
|
+
const isStub = i === 0 || i === points.length - 2;
|
|
2399
|
+
for (const box of boxes) if (segmentHugsRect(points[i], points[i + 1], box, reach, isStub)) n += 1;
|
|
2400
|
+
}
|
|
2401
|
+
return n;
|
|
2402
|
+
}
|
|
2403
|
+
//#endregion
|
|
2404
|
+
//#region src/layout/elk/layout-arranged.ts
|
|
2405
|
+
/** Inside a row of columns, pack top→bottom; inside stacked bands, pack left→right. */
|
|
2406
|
+
function cellFlowDirection(parentArrange) {
|
|
2407
|
+
if (parentArrange === "row") return "TD";
|
|
2408
|
+
if (parentArrange === "stack") return "LR";
|
|
2409
|
+
return "TD";
|
|
2410
|
+
}
|
|
2411
|
+
const NODE_CELL_PREFIX = "__node__:";
|
|
2412
|
+
/** Fallback leaf gap when no authored `gap` — roomier than the old hard-coded 16px. */
|
|
2413
|
+
function defaultLeafGap(density) {
|
|
2414
|
+
switch (density) {
|
|
2415
|
+
case "compact": return 20;
|
|
2416
|
+
case "spacious": return 40;
|
|
2417
|
+
default: return 28;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
function resolveLeafGap(gap, options) {
|
|
2421
|
+
const scale = options.spacingScale ?? 1;
|
|
2422
|
+
const base = gap != null ? resolveArrangeGap(gap) : defaultLeafGap(options.density);
|
|
2423
|
+
return Math.max(0, Math.round(base * scale));
|
|
2424
|
+
}
|
|
2425
|
+
function resolveTrackGap(gap, options) {
|
|
2426
|
+
const scale = options.spacingScale ?? 1;
|
|
2427
|
+
return Math.max(0, Math.round(resolveArrangeGap(gap) * scale));
|
|
2428
|
+
}
|
|
2429
|
+
function nodeCellId(nodeId) {
|
|
2430
|
+
return `${NODE_CELL_PREFIX}${nodeId}`;
|
|
2431
|
+
}
|
|
2432
|
+
function parseNodeCellId(cellId) {
|
|
2433
|
+
return cellId.startsWith(NODE_CELL_PREFIX) ? cellId.slice(9) : null;
|
|
2434
|
+
}
|
|
2435
|
+
/** Pack wrap width — wider densities keep more siblings on one row. */
|
|
2436
|
+
function packMaxRowWidth(density) {
|
|
2437
|
+
switch (density) {
|
|
2438
|
+
case "compact": return 420;
|
|
2439
|
+
case "spacious": return 720;
|
|
2440
|
+
default: return 560;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
function needsRegionArrange(graph, options) {
|
|
2444
|
+
if (options.arrange === "stack" || options.arrange === "row" || options.arrange === "grid") return true;
|
|
2445
|
+
return graph.groups.some((g) => groupHasRegionArrange(g));
|
|
2446
|
+
}
|
|
2447
|
+
function ranksFromFixed(nodes, direction) {
|
|
2448
|
+
const dir = direction ?? "LR";
|
|
2449
|
+
const horizontal = dir === "LR" || dir === "RL";
|
|
2450
|
+
const sorted = [...nodes].sort((a, b) => {
|
|
2451
|
+
const aPrimary = horizontal ? a.bounds.x : a.bounds.y;
|
|
2452
|
+
const bPrimary = horizontal ? b.bounds.x : b.bounds.y;
|
|
2453
|
+
if (aPrimary !== bPrimary) return aPrimary - bPrimary;
|
|
2454
|
+
return (horizontal ? a.bounds.y : a.bounds.x) - (horizontal ? b.bounds.y : b.bounds.x);
|
|
2455
|
+
});
|
|
2456
|
+
const rankOf = /* @__PURE__ */ new Map();
|
|
2457
|
+
let rank = 0;
|
|
2458
|
+
let lastPrimary = Number.NEGATIVE_INFINITY;
|
|
2459
|
+
for (const n of sorted) {
|
|
2460
|
+
const primary = horizontal ? n.bounds.x : n.bounds.y;
|
|
2461
|
+
if (primary - lastPrimary > 8) {
|
|
2462
|
+
if (lastPrimary !== Number.NEGATIVE_INFINITY) rank += 1;
|
|
2463
|
+
lastPrimary = primary;
|
|
2464
|
+
}
|
|
2465
|
+
rankOf.set(n.nodeId, rank);
|
|
2466
|
+
}
|
|
2467
|
+
const orderInRank = /* @__PURE__ */ new Map();
|
|
2468
|
+
return sorted.map((n) => {
|
|
2469
|
+
const r = rankOf.get(n.nodeId) ?? 0;
|
|
2470
|
+
const order = orderInRank.get(r) ?? 0;
|
|
2471
|
+
orderInRank.set(r, order + 1);
|
|
2472
|
+
return {
|
|
2473
|
+
...n,
|
|
2474
|
+
rank: r,
|
|
2475
|
+
order
|
|
2476
|
+
};
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
function packNodes(nodeIds, measureMap, mode, density, gapPx = 28) {
|
|
2480
|
+
const out = /* @__PURE__ */ new Map();
|
|
2481
|
+
let x = 0;
|
|
2482
|
+
let y = 0;
|
|
2483
|
+
let rowH = 0;
|
|
2484
|
+
const maxRowW = packMaxRowWidth(density);
|
|
2485
|
+
const gap = Math.max(0, gapPx);
|
|
2486
|
+
const rows = [];
|
|
2487
|
+
let row = [];
|
|
2488
|
+
for (const id of nodeIds) {
|
|
2489
|
+
const m = measureMap.get(id);
|
|
2490
|
+
if (!m) continue;
|
|
2491
|
+
if (mode === "stack") {
|
|
2492
|
+
out.set(id, {
|
|
2493
|
+
x: 0,
|
|
2494
|
+
y,
|
|
2495
|
+
width: m.width,
|
|
2496
|
+
height: m.height
|
|
2497
|
+
});
|
|
2498
|
+
y += m.height + gap;
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
if (x > 0 && x + m.width > maxRowW) {
|
|
2502
|
+
rows.push(row);
|
|
2503
|
+
row = [];
|
|
2504
|
+
x = 0;
|
|
2505
|
+
y += rowH + gap;
|
|
2506
|
+
rowH = 0;
|
|
2507
|
+
}
|
|
2508
|
+
out.set(id, {
|
|
2509
|
+
x,
|
|
2510
|
+
y,
|
|
2511
|
+
width: m.width,
|
|
2512
|
+
height: m.height
|
|
2513
|
+
});
|
|
2514
|
+
row.push(id);
|
|
2515
|
+
x += m.width + gap;
|
|
2516
|
+
rowH = Math.max(rowH, m.height);
|
|
2517
|
+
}
|
|
2518
|
+
if (row.length) rows.push(row);
|
|
2519
|
+
if (mode === "stack" && out.size > 1) {
|
|
2520
|
+
const maxW = Math.max(...[...out.values()].map((b) => b.width));
|
|
2521
|
+
for (const [id, b] of out) out.set(id, {
|
|
2522
|
+
...b,
|
|
2523
|
+
x: (maxW - b.width) / 2
|
|
2524
|
+
});
|
|
2525
|
+
} else if (mode === "pack" && rows.length > 0) {
|
|
2526
|
+
const colCount = Math.max(...rows.map((r) => r.length), 0);
|
|
2527
|
+
const colW = Array.from({ length: colCount }, () => 0);
|
|
2528
|
+
const rowHeights = Array.from({ length: rows.length }, () => 0);
|
|
2529
|
+
for (let r = 0; r < rows.length; r++) {
|
|
2530
|
+
const ids = rows[r];
|
|
2531
|
+
for (let c = 0; c < ids.length; c++) {
|
|
2532
|
+
const b = out.get(ids[c]);
|
|
2533
|
+
colW[c] = Math.max(colW[c], b.width);
|
|
2534
|
+
rowHeights[r] = Math.max(rowHeights[r], b.height);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
let gy = 0;
|
|
2538
|
+
for (let r = 0; r < rows.length; r++) {
|
|
2539
|
+
const ids = rows[r];
|
|
2540
|
+
let gx = 0;
|
|
2541
|
+
for (let c = 0; c < ids.length; c++) {
|
|
2542
|
+
const id = ids[c];
|
|
2543
|
+
const b = out.get(id);
|
|
2544
|
+
out.set(id, {
|
|
2545
|
+
x: gx,
|
|
2546
|
+
y: gy,
|
|
2547
|
+
width: b.width,
|
|
2548
|
+
height: b.height
|
|
2549
|
+
});
|
|
2550
|
+
gx += colW[c] + gap;
|
|
2551
|
+
}
|
|
2552
|
+
gy += rowHeights[r] + gap;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
return out;
|
|
2556
|
+
}
|
|
2557
|
+
function aabbOf(rects) {
|
|
2558
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2559
|
+
let any = false;
|
|
2560
|
+
for (const b of rects) {
|
|
2561
|
+
any = true;
|
|
2562
|
+
minX = Math.min(minX, b.x);
|
|
2563
|
+
minY = Math.min(minY, b.y);
|
|
2564
|
+
maxX = Math.max(maxX, b.x + b.width);
|
|
2565
|
+
maxY = Math.max(maxY, b.y + b.height);
|
|
2566
|
+
}
|
|
2567
|
+
if (!any) return null;
|
|
2568
|
+
return {
|
|
2569
|
+
x: minX,
|
|
2570
|
+
y: minY,
|
|
2571
|
+
width: maxX - minX,
|
|
2572
|
+
height: maxY - minY
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
function offsetRects(rects, dx, dy) {
|
|
2576
|
+
for (const [id, b] of rects) rects.set(id, {
|
|
2577
|
+
...b,
|
|
2578
|
+
x: b.x + dx,
|
|
2579
|
+
y: b.y + dy
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Local ELK for nodes inside a group (and edges wholly inside it).
|
|
2584
|
+
* Returns node bounds relative to (0,0) content origin (no group padding).
|
|
2585
|
+
*/
|
|
2586
|
+
async function layoutCellFlow(graph, group, measured, options, flowDirection) {
|
|
2587
|
+
const nodeIds = new Set(collectDescendantNodeIds(graph, group.id));
|
|
2588
|
+
if (nodeIds.size === 0) return /* @__PURE__ */ new Map();
|
|
2589
|
+
const subGraph = {
|
|
2590
|
+
...graph,
|
|
2591
|
+
nodes: graph.nodes.filter((n) => nodeIds.has(n.id)),
|
|
2592
|
+
edges: graph.edges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to)),
|
|
2593
|
+
groups: []
|
|
2594
|
+
};
|
|
2595
|
+
const subMeasured = measured.filter((m) => nodeIds.has(m.nodeId));
|
|
2596
|
+
if (subMeasured.length === 0) return /* @__PURE__ */ new Map();
|
|
2597
|
+
if (subGraph.edges.length === 0) return packNodes(subMeasured.map((m) => m.nodeId), new Map(measured.map((m) => [m.nodeId, m])), flowDirection === "TD" || flowDirection === "BT" ? "stack" : "pack", options.density, resolveLeafGap(void 0, options));
|
|
2598
|
+
const elkGraph = buildElkGraph(subGraph, subMeasured, {
|
|
2599
|
+
...options,
|
|
2600
|
+
direction: flowDirection,
|
|
2601
|
+
groupLayout: "flat"
|
|
2602
|
+
});
|
|
2603
|
+
const laid = await getElk().layout(elkGraph);
|
|
2604
|
+
const out = /* @__PURE__ */ new Map();
|
|
2605
|
+
for (const child of laid.children ?? []) {
|
|
2606
|
+
if (!nodeIds.has(child.id)) continue;
|
|
2607
|
+
out.set(child.id, {
|
|
2608
|
+
x: child.x ?? 0,
|
|
2609
|
+
y: child.y ?? 0,
|
|
2610
|
+
width: child.width ?? 0,
|
|
2611
|
+
height: child.height ?? 0
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
const box = aabbOf(out.values());
|
|
2615
|
+
if (box && (box.x !== 0 || box.y !== 0)) offsetRects(out, -box.x, -box.y);
|
|
2616
|
+
return out;
|
|
2617
|
+
}
|
|
2618
|
+
/** Center (or start/end) content inside a stretched slot's padded inner box. */
|
|
2619
|
+
function contentOriginInSlot(slot, pad, contentBox, align) {
|
|
2620
|
+
const innerW = Math.max(0, slot.width - pad.left - pad.right);
|
|
2621
|
+
const innerH = Math.max(0, slot.height - pad.top - pad.bottom);
|
|
2622
|
+
const freeX = Math.max(0, innerW - contentBox.width);
|
|
2623
|
+
const freeY = Math.max(0, innerH - contentBox.height);
|
|
2624
|
+
const ox = align === "end" ? freeX : align === "start" ? 0 : freeX / 2;
|
|
2625
|
+
const oy = align === "end" ? freeY : align === "start" ? 0 : freeY / 2;
|
|
2626
|
+
return {
|
|
2627
|
+
x: slot.x + pad.left + ox,
|
|
2628
|
+
y: slot.y + pad.top + oy
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
function collectDescendantNodeIds(graph, groupId) {
|
|
2632
|
+
const group = graph.groups.find((g) => g.id === groupId);
|
|
2633
|
+
if (!group) return [];
|
|
2634
|
+
const ids = [...group.nodeIds];
|
|
2635
|
+
const stack = [...group.childGroupIds];
|
|
2636
|
+
while (stack.length) {
|
|
2637
|
+
const cid = stack.pop();
|
|
2638
|
+
const child = graph.groups.find((g) => g.id === cid);
|
|
2639
|
+
if (!child) continue;
|
|
2640
|
+
ids.push(...child.nodeIds);
|
|
2641
|
+
stack.push(...child.childGroupIds);
|
|
2642
|
+
}
|
|
2643
|
+
return ids;
|
|
2644
|
+
}
|
|
2645
|
+
function childGroupsOf(graph, parentId) {
|
|
2646
|
+
return graph.groups.filter((g) => g.parentId === parentId);
|
|
2647
|
+
}
|
|
2648
|
+
/** Reconstruct declaration order when `members` is missing (older compiled graphs). */
|
|
2649
|
+
function fallbackTrackMembers(graph, parent, children) {
|
|
2650
|
+
const items = [];
|
|
2651
|
+
for (const id of parent.nodeIds) {
|
|
2652
|
+
const n = graph.nodes.find((node) => node.id === id);
|
|
2653
|
+
items.push({
|
|
2654
|
+
kind: "node",
|
|
2655
|
+
id,
|
|
2656
|
+
order: n?.sourceRange?.start.offset ?? Number.MAX_SAFE_INTEGER
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
for (const child of children) {
|
|
2660
|
+
let order = Number.MAX_SAFE_INTEGER;
|
|
2661
|
+
for (const nid of collectDescendantNodeIds(graph, child.id)) {
|
|
2662
|
+
const o = graph.nodes.find((node) => node.id === nid)?.sourceRange?.start.offset;
|
|
2663
|
+
if (o != null) order = Math.min(order, o);
|
|
2664
|
+
}
|
|
2665
|
+
items.push({
|
|
2666
|
+
kind: "group",
|
|
2667
|
+
id: child.id,
|
|
2668
|
+
order
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
items.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
2672
|
+
return items.map(({ kind, id }) => kind === "node" ? {
|
|
2673
|
+
kind: "node",
|
|
2674
|
+
id
|
|
2675
|
+
} : {
|
|
2676
|
+
kind: "group",
|
|
2677
|
+
id
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
/** Diagram-level declaration order across ungrouped nodes and top-level regions. */
|
|
2681
|
+
function topLevelTrackMembers(graph, children) {
|
|
2682
|
+
const items = graph.nodes.filter((node) => node.groupId == null).map((node) => ({
|
|
2683
|
+
kind: "node",
|
|
2684
|
+
id: node.id,
|
|
2685
|
+
order: node.sourceRange?.start.offset ?? Number.MAX_SAFE_INTEGER
|
|
2686
|
+
}));
|
|
2687
|
+
for (const child of children) {
|
|
2688
|
+
let order = Number.MAX_SAFE_INTEGER;
|
|
2689
|
+
for (const nodeId of collectDescendantNodeIds(graph, child.id)) {
|
|
2690
|
+
const offset = graph.nodes.find((node) => node.id === nodeId)?.sourceRange?.start.offset;
|
|
2691
|
+
if (offset != null) order = Math.min(order, offset);
|
|
2692
|
+
}
|
|
2693
|
+
items.push({
|
|
2694
|
+
kind: "group",
|
|
2695
|
+
id: child.id,
|
|
2696
|
+
order
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
items.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
2700
|
+
return items.map(({ kind, id }) => ({
|
|
2701
|
+
kind,
|
|
2702
|
+
id
|
|
2703
|
+
}));
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Layout one arranged parent: size children, pack tracks, write world node/group bounds.
|
|
2707
|
+
* Direct member nodes participate as track cells (declaration order via `members`).
|
|
2708
|
+
*/
|
|
2709
|
+
async function layoutArrangedParent(ctx, parent) {
|
|
2710
|
+
const arrange = parent?.arrange ?? ctx.options.arrange;
|
|
2711
|
+
if (!arrange) return;
|
|
2712
|
+
const children = childGroupsOf(ctx.graph, parent?.id);
|
|
2713
|
+
const trackMembers = parent?.members && parent.members.length > 0 ? parent.members : parent ? fallbackTrackMembers(ctx.graph, parent, children) : topLevelTrackMembers(ctx.graph, children);
|
|
2714
|
+
if (trackMembers.length === 0) return;
|
|
2715
|
+
const align = parent?.align ?? ctx.options.align ?? "stretch";
|
|
2716
|
+
const flowDirection = cellFlowDirection(arrange);
|
|
2717
|
+
const childById = new Map(children.map((g) => [g.id, g]));
|
|
2718
|
+
for (const child of children) if (groupHasRegionArrange(child)) await layoutArrangedParent(ctx, child);
|
|
2719
|
+
const cells = [];
|
|
2720
|
+
const childContent = /* @__PURE__ */ new Map();
|
|
2721
|
+
for (const member of trackMembers) {
|
|
2722
|
+
if (member.kind === "node") {
|
|
2723
|
+
const m = ctx.measureMap.get(member.id);
|
|
2724
|
+
if (!m) continue;
|
|
2725
|
+
cells.push({
|
|
2726
|
+
groupId: nodeCellId(member.id),
|
|
2727
|
+
width: m.width,
|
|
2728
|
+
height: m.height
|
|
2729
|
+
});
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
const child = childById.get(member.id);
|
|
2733
|
+
if (!child) continue;
|
|
2734
|
+
let content;
|
|
2735
|
+
if (ctx.groupBounds.has(child.id) && groupHasRegionArrange(child)) {
|
|
2736
|
+
const gb = ctx.groupBounds.get(child.id);
|
|
2737
|
+
cells.push({
|
|
2738
|
+
groupId: child.id,
|
|
2739
|
+
width: gb.width,
|
|
2740
|
+
height: gb.height,
|
|
2741
|
+
column: child.column,
|
|
2742
|
+
row: child.row,
|
|
2743
|
+
colSpan: child.colSpan ?? child.span,
|
|
2744
|
+
rowSpan: child.rowSpan
|
|
2745
|
+
});
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
const mode = child.cellArrange ?? "flow";
|
|
2749
|
+
const leafGap = resolveLeafGap(child.gap, ctx.options);
|
|
2750
|
+
if (mode === "pack" || mode === "stack") content = packNodes(child.nodeIds, ctx.measureMap, mode, ctx.options.density, leafGap);
|
|
2751
|
+
else content = await layoutCellFlow(ctx.graph, child, ctx.measured, ctx.options, flowDirection);
|
|
2752
|
+
childContent.set(child.id, content);
|
|
2753
|
+
const pad = paddingForGroup(child);
|
|
2754
|
+
const contentBox = aabbOf(content.values()) ?? {
|
|
2755
|
+
x: 0,
|
|
2756
|
+
y: 0,
|
|
2757
|
+
width: 80,
|
|
2758
|
+
height: 40
|
|
2759
|
+
};
|
|
2760
|
+
const width = contentBox.width + pad.left + pad.right;
|
|
2761
|
+
const height = contentBox.height + pad.top + pad.bottom;
|
|
2762
|
+
cells.push({
|
|
2763
|
+
groupId: child.id,
|
|
2764
|
+
width,
|
|
2765
|
+
height,
|
|
2766
|
+
column: child.column,
|
|
2767
|
+
row: child.row,
|
|
2768
|
+
colSpan: child.colSpan ?? child.span,
|
|
2769
|
+
rowSpan: child.rowSpan
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
const placed = regionArrange({
|
|
2773
|
+
arrange,
|
|
2774
|
+
align,
|
|
2775
|
+
gap: resolveTrackGap(parent?.gap ?? ctx.options.gap, ctx.options),
|
|
2776
|
+
columns: parent?.columns ?? ctx.options.columns,
|
|
2777
|
+
rows: parent?.rows ?? ctx.options.rows,
|
|
2778
|
+
cells,
|
|
2779
|
+
origin: {
|
|
2780
|
+
x: 64,
|
|
2781
|
+
y: 64
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
for (const slot of placed) {
|
|
2785
|
+
const directNodeId = parseNodeCellId(slot.groupId);
|
|
2786
|
+
if (directNodeId) {
|
|
2787
|
+
const m = ctx.measureMap.get(directNodeId);
|
|
2788
|
+
if (m) {
|
|
2789
|
+
const contentBox = {
|
|
2790
|
+
x: 0,
|
|
2791
|
+
y: 0,
|
|
2792
|
+
width: m.width,
|
|
2793
|
+
height: m.height
|
|
2794
|
+
};
|
|
2795
|
+
const origin = contentOriginInSlot(slot.bounds, {
|
|
2796
|
+
top: 0,
|
|
2797
|
+
right: 0,
|
|
2798
|
+
bottom: 0,
|
|
2799
|
+
left: 0
|
|
2800
|
+
}, contentBox, align);
|
|
2801
|
+
ctx.nodeBounds.set(directNodeId, {
|
|
2802
|
+
x: origin.x,
|
|
2803
|
+
y: origin.y,
|
|
2804
|
+
width: m.width,
|
|
2805
|
+
height: m.height
|
|
2806
|
+
});
|
|
2807
|
+
} else ctx.nodeBounds.set(directNodeId, { ...slot.bounds });
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
const child = childById.get(slot.groupId);
|
|
2811
|
+
if (!child) continue;
|
|
2812
|
+
const pad = paddingForGroup(child);
|
|
2813
|
+
const content = childContent.get(child.id);
|
|
2814
|
+
if (content) {
|
|
2815
|
+
ctx.groupBounds.set(child.id, slot.bounds);
|
|
2816
|
+
const contentBox = aabbOf(content.values()) ?? {
|
|
2817
|
+
x: 0,
|
|
2818
|
+
y: 0,
|
|
2819
|
+
width: 0,
|
|
2820
|
+
height: 0
|
|
2821
|
+
};
|
|
2822
|
+
const origin = contentOriginInSlot(slot.bounds, pad, contentBox, align);
|
|
2823
|
+
for (const [nodeId, local] of content) ctx.nodeBounds.set(nodeId, {
|
|
2824
|
+
x: origin.x + (local.x - contentBox.x),
|
|
2825
|
+
y: origin.y + (local.y - contentBox.y),
|
|
2826
|
+
width: local.width,
|
|
2827
|
+
height: local.height
|
|
2828
|
+
});
|
|
2829
|
+
} else if (groupHasRegionArrange(child)) {
|
|
2830
|
+
const prev = ctx.groupBounds.get(child.id);
|
|
2831
|
+
if (prev) {
|
|
2832
|
+
const dx = slot.bounds.x - prev.x;
|
|
2833
|
+
const dy = slot.bounds.y - prev.y;
|
|
2834
|
+
ctx.groupBounds.set(child.id, slot.bounds);
|
|
2835
|
+
if (dx !== 0 || dy !== 0) {
|
|
2836
|
+
for (const nid of collectDescendantNodeIds(ctx.graph, child.id)) {
|
|
2837
|
+
const b = ctx.nodeBounds.get(nid);
|
|
2838
|
+
if (b) ctx.nodeBounds.set(nid, {
|
|
2839
|
+
...b,
|
|
2840
|
+
x: b.x + dx,
|
|
2841
|
+
y: b.y + dy
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
const stack = [...child.childGroupIds];
|
|
2845
|
+
while (stack.length) {
|
|
2846
|
+
const cid = stack.pop();
|
|
2847
|
+
const gb = ctx.groupBounds.get(cid);
|
|
2848
|
+
if (gb) ctx.groupBounds.set(cid, {
|
|
2849
|
+
...gb,
|
|
2850
|
+
x: gb.x + dx,
|
|
2851
|
+
y: gb.y + dy
|
|
2852
|
+
});
|
|
2853
|
+
const g = ctx.graph.groups.find((x) => x.id === cid);
|
|
2854
|
+
if (g) stack.push(...g.childGroupIds);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
} else ctx.groupBounds.set(child.id, slot.bounds);
|
|
2858
|
+
} else ctx.groupBounds.set(child.id, slot.bounds);
|
|
2859
|
+
}
|
|
2860
|
+
if (parent) {
|
|
2861
|
+
const box = aabbOf(placed.map((p) => p.bounds));
|
|
2862
|
+
if (box) {
|
|
2863
|
+
const pad = paddingForGroup(parent);
|
|
2864
|
+
ctx.groupBounds.set(parent.id, {
|
|
2865
|
+
x: box.x - pad.left,
|
|
2866
|
+
y: box.y - pad.top,
|
|
2867
|
+
width: box.width + pad.left + pad.right,
|
|
2868
|
+
height: box.height + pad.top + pad.bottom
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
function stubOrthogonalEdge(edgeId, fromId, toId, from, to, obstacles, pad, tFrom, tTo) {
|
|
2874
|
+
const points = routeOrthogonalAvoiding(from, to, obstacles, pad, tFrom, tTo);
|
|
2875
|
+
const start = points[0];
|
|
2876
|
+
const end = points[points.length - 1];
|
|
2877
|
+
const bendPoints = points.slice(1, -1);
|
|
2878
|
+
return {
|
|
2879
|
+
id: edgeId,
|
|
2880
|
+
sources: [fromId],
|
|
2881
|
+
targets: [toId],
|
|
2882
|
+
sections: [{
|
|
2883
|
+
id: `${edgeId}_s0`,
|
|
2884
|
+
startPoint: {
|
|
2885
|
+
x: start.x,
|
|
2886
|
+
y: start.y
|
|
2887
|
+
},
|
|
2888
|
+
endPoint: {
|
|
2889
|
+
x: end.x,
|
|
2890
|
+
y: end.y
|
|
2891
|
+
},
|
|
2892
|
+
bendPoints
|
|
2893
|
+
}]
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
function fanSlot(index, count) {
|
|
2897
|
+
if (count <= 1) return .5;
|
|
2898
|
+
return .28 + .44 * index / (count - 1);
|
|
2899
|
+
}
|
|
2900
|
+
function primaryExitSide(from, to) {
|
|
2901
|
+
return preferredSidePair(from, to)[0];
|
|
2902
|
+
}
|
|
2903
|
+
function primaryEntrySide(from, to) {
|
|
2904
|
+
return preferredSidePair(from, to)[1];
|
|
2905
|
+
}
|
|
2906
|
+
/** Sort key along a face — left→right for N/S, top→bottom for E/W. */
|
|
2907
|
+
function faceSortKey(node, other, side) {
|
|
2908
|
+
const ox = other.x + other.width / 2;
|
|
2909
|
+
const oy = other.y + other.height / 2;
|
|
2910
|
+
if (side === "N" || side === "S") return ox - node.x;
|
|
2911
|
+
return oy - node.y;
|
|
2912
|
+
}
|
|
2913
|
+
function rectsOverlap(a, b) {
|
|
2914
|
+
return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y);
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Channel between facing sides of two boxes (primary axis only).
|
|
2918
|
+
* Used to decide which group chrome actually sits *between* the endpoints.
|
|
2919
|
+
*/
|
|
2920
|
+
function facingStrip(from, to) {
|
|
2921
|
+
const [fs] = preferredSidePair(from, to);
|
|
2922
|
+
if (fs === "E" || fs === "W") {
|
|
2923
|
+
const x0 = fs === "E" ? from.x + from.width : to.x + to.width;
|
|
2924
|
+
const x1 = fs === "E" ? to.x : from.x;
|
|
2925
|
+
if (x1 <= x0 + 1) return null;
|
|
2926
|
+
const y0 = Math.min(from.y, to.y);
|
|
2927
|
+
const y1 = Math.max(from.y + from.height, to.y + to.height);
|
|
2928
|
+
return {
|
|
2929
|
+
x: x0,
|
|
2930
|
+
y: y0,
|
|
2931
|
+
width: x1 - x0,
|
|
2932
|
+
height: Math.max(1, y1 - y0)
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
const y0 = fs === "S" ? from.y + from.height : to.y + to.height;
|
|
2936
|
+
const y1 = fs === "S" ? to.y : from.y;
|
|
2937
|
+
if (y1 <= y0 + 1) return null;
|
|
2938
|
+
const x0 = Math.min(from.x, to.x);
|
|
2939
|
+
const x1 = Math.max(from.x + from.width, to.x + to.width);
|
|
2940
|
+
return {
|
|
2941
|
+
x: x0,
|
|
2942
|
+
y: y0,
|
|
2943
|
+
width: Math.max(1, x1 - x0),
|
|
2944
|
+
height: y1 - y0
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Fan attach slots only among edges that share the same exit/entry face.
|
|
2949
|
+
* A lone edge on a face stays centered (t=0.5).
|
|
2950
|
+
*/
|
|
2951
|
+
function sideAwareFanSlots(routed, nodeBounds) {
|
|
2952
|
+
const tFrom = /* @__PURE__ */ new Map();
|
|
2953
|
+
const tTo = /* @__PURE__ */ new Map();
|
|
2954
|
+
const outGroups = /* @__PURE__ */ new Map();
|
|
2955
|
+
const inGroups = /* @__PURE__ */ new Map();
|
|
2956
|
+
for (const e of routed) {
|
|
2957
|
+
const from = nodeBounds.get(e.from);
|
|
2958
|
+
const to = nodeBounds.get(e.to);
|
|
2959
|
+
const outSide = primaryExitSide(from, to);
|
|
2960
|
+
const inSide = primaryEntrySide(from, to);
|
|
2961
|
+
const outKey = `${e.from}:${outSide}`;
|
|
2962
|
+
const inKey = `${e.to}:${inSide}`;
|
|
2963
|
+
if (!outGroups.has(outKey)) outGroups.set(outKey, []);
|
|
2964
|
+
if (!inGroups.has(inKey)) inGroups.set(inKey, []);
|
|
2965
|
+
outGroups.get(outKey).push({
|
|
2966
|
+
edgeId: e.id,
|
|
2967
|
+
key: faceSortKey(from, to, outSide)
|
|
2968
|
+
});
|
|
2969
|
+
inGroups.get(inKey).push({
|
|
2970
|
+
edgeId: e.id,
|
|
2971
|
+
key: faceSortKey(to, from, inSide)
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
for (const members of outGroups.values()) {
|
|
2975
|
+
members.sort((a, b) => a.key - b.key);
|
|
2976
|
+
members.forEach((m, i) => tFrom.set(m.edgeId, fanSlot(i, members.length)));
|
|
2977
|
+
}
|
|
2978
|
+
for (const members of inGroups.values()) {
|
|
2979
|
+
members.sort((a, b) => a.key - b.key);
|
|
2980
|
+
members.forEach((m, i) => tTo.set(m.edgeId, fanSlot(i, members.length)));
|
|
2981
|
+
}
|
|
2982
|
+
return {
|
|
2983
|
+
tFrom,
|
|
2984
|
+
tTo
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
function buildFixedElkGraph(graph, nodeBounds, groupBounds, options) {
|
|
2988
|
+
const children = [];
|
|
2989
|
+
for (const node of graph.nodes) {
|
|
2990
|
+
const b = nodeBounds.get(node.id);
|
|
2991
|
+
if (!b) continue;
|
|
2992
|
+
children.push({
|
|
2993
|
+
id: node.id,
|
|
2994
|
+
x: b.x,
|
|
2995
|
+
y: b.y,
|
|
2996
|
+
width: b.width,
|
|
2997
|
+
height: b.height
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
const clearance = Math.max(28, options.edgeNodeSpacing ?? 28);
|
|
3001
|
+
const avoidPad = Math.min(12, clearance);
|
|
3002
|
+
const routed = graph.edges.filter((e) => nodeBounds.has(e.from) && nodeBounds.has(e.to));
|
|
3003
|
+
const { tFrom, tTo } = sideAwareFanSlots(routed, nodeBounds);
|
|
3004
|
+
const memberCache = /* @__PURE__ */ new Map();
|
|
3005
|
+
const membersOf = (groupId) => {
|
|
3006
|
+
let set = memberCache.get(groupId);
|
|
3007
|
+
if (!set) {
|
|
3008
|
+
set = new Set(collectDescendantNodeIds(graph, groupId));
|
|
3009
|
+
memberCache.set(groupId, set);
|
|
3010
|
+
}
|
|
3011
|
+
return set;
|
|
3012
|
+
};
|
|
3013
|
+
const descendantGroups = (groupId) => {
|
|
3014
|
+
const out = /* @__PURE__ */ new Set([groupId]);
|
|
3015
|
+
const stack = [groupId];
|
|
3016
|
+
while (stack.length) {
|
|
3017
|
+
const id = stack.pop();
|
|
3018
|
+
const g = graph.groups.find((x) => x.id === id);
|
|
3019
|
+
if (!g) continue;
|
|
3020
|
+
for (const child of g.childGroupIds) {
|
|
3021
|
+
if (out.has(child)) continue;
|
|
3022
|
+
out.add(child);
|
|
3023
|
+
stack.push(child);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
return out;
|
|
3027
|
+
};
|
|
3028
|
+
/** Groups that contain `nodeId` but not `otherId`, plus their nested groups. */
|
|
3029
|
+
const exclusiveSubtree = (nodeId, otherId) => {
|
|
3030
|
+
const skip = /* @__PURE__ */ new Set();
|
|
3031
|
+
for (const g of graph.groups) {
|
|
3032
|
+
const members = membersOf(g.id);
|
|
3033
|
+
if (!members.has(nodeId) || members.has(otherId)) continue;
|
|
3034
|
+
for (const id of descendantGroups(g.id)) skip.add(id);
|
|
3035
|
+
}
|
|
3036
|
+
return skip;
|
|
3037
|
+
};
|
|
3038
|
+
const edges = routed.map((e) => {
|
|
3039
|
+
const from = nodeBounds.get(e.from);
|
|
3040
|
+
const to = nodeBounds.get(e.to);
|
|
3041
|
+
const obstacles = [];
|
|
3042
|
+
for (const [id, b] of nodeBounds) {
|
|
3043
|
+
if (id === e.from || id === e.to) continue;
|
|
3044
|
+
obstacles.push(b);
|
|
3045
|
+
}
|
|
3046
|
+
const strip = facingStrip(from, to);
|
|
3047
|
+
if (strip) {
|
|
3048
|
+
const skip = /* @__PURE__ */ new Set([...exclusiveSubtree(e.from, e.to), ...exclusiveSubtree(e.to, e.from)]);
|
|
3049
|
+
for (const [gid, gb] of groupBounds) {
|
|
3050
|
+
if (skip.has(gid)) continue;
|
|
3051
|
+
const members = membersOf(gid);
|
|
3052
|
+
if (members.has(e.from) || members.has(e.to)) continue;
|
|
3053
|
+
if (!rectsOverlap(gb, strip)) continue;
|
|
3054
|
+
obstacles.push(gb);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
return stubOrthogonalEdge(e.id, e.from, e.to, from, to, obstacles, avoidPad, tFrom.get(e.id) ?? .5, tTo.get(e.id) ?? .5);
|
|
3058
|
+
});
|
|
3059
|
+
return {
|
|
3060
|
+
id: "root",
|
|
3061
|
+
layoutOptions: {
|
|
3062
|
+
"elk.algorithm": "fixed",
|
|
3063
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
3064
|
+
"elk.direction": options.direction === "TD" ? "DOWN" : options.direction === "BT" ? "UP" : options.direction === "RL" ? "LEFT" : "RIGHT",
|
|
3065
|
+
"elk.spacing.nodeNode": "40",
|
|
3066
|
+
"elk.spacing.edgeNode": String(clearance),
|
|
3067
|
+
"elk.spacing.edgeEdge": String(options.edgeEdgeSpacing ?? 18),
|
|
3068
|
+
"elk.padding": `[top=64,left=64,bottom=64,right=64]`
|
|
3069
|
+
},
|
|
3070
|
+
children,
|
|
3071
|
+
edges
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* Region-arrange path: local cell layouts → track packing → fixed-position ELK for edges.
|
|
3076
|
+
*/
|
|
3077
|
+
async function layoutAndRouteArranged(graph, measured, options) {
|
|
3078
|
+
const t0 = performance.now();
|
|
3079
|
+
const direction = options.direction ?? "LR";
|
|
3080
|
+
const measureMap = new Map(measured.map((m) => [m.nodeId, m]));
|
|
3081
|
+
const ctx = {
|
|
3082
|
+
graph,
|
|
3083
|
+
measured,
|
|
3084
|
+
measureMap,
|
|
3085
|
+
options,
|
|
3086
|
+
nodeBounds: /* @__PURE__ */ new Map(),
|
|
3087
|
+
groupBounds: /* @__PURE__ */ new Map()
|
|
3088
|
+
};
|
|
3089
|
+
if (options.arrange) await layoutArrangedParent(ctx, null);
|
|
3090
|
+
else {
|
|
3091
|
+
const roots = graph.groups.filter((g) => groupHasRegionArrange(g)).filter((g) => {
|
|
3092
|
+
if (!g.parentId) return true;
|
|
3093
|
+
const parent = graph.groups.find((p) => p.id === g.parentId);
|
|
3094
|
+
return !parent || !groupHasRegionArrange(parent);
|
|
3095
|
+
});
|
|
3096
|
+
for (const root of roots) await layoutArrangedParent(ctx, root);
|
|
3097
|
+
}
|
|
3098
|
+
const placedIds = new Set(ctx.nodeBounds.keys());
|
|
3099
|
+
const residualIds = graph.nodes.filter((n) => !placedIds.has(n.id)).map((n) => n.id);
|
|
3100
|
+
if (residualIds.length) {
|
|
3101
|
+
const residualSet = new Set(residualIds);
|
|
3102
|
+
const sources = [];
|
|
3103
|
+
const sinks = [];
|
|
3104
|
+
const other = [];
|
|
3105
|
+
for (const id of residualIds) {
|
|
3106
|
+
const feedsIn = graph.edges.some((e) => e.from === id && !residualSet.has(e.to));
|
|
3107
|
+
const fedFrom = graph.edges.some((e) => e.to === id && !residualSet.has(e.from));
|
|
3108
|
+
if (feedsIn && !fedFrom) sources.push(id);
|
|
3109
|
+
else if (fedFrom && !feedsIn) sinks.push(id);
|
|
3110
|
+
else other.push(id);
|
|
3111
|
+
}
|
|
3112
|
+
const placePack = (ids, side) => {
|
|
3113
|
+
if (ids.length === 0) return;
|
|
3114
|
+
const packed = packNodes(ids, measureMap, direction === "TD" || direction === "BT" ? "pack" : "stack", options.density, resolveLeafGap(void 0, options));
|
|
3115
|
+
const packedBox = aabbOf(packed.values()) ?? {
|
|
3116
|
+
x: 0,
|
|
3117
|
+
y: 0,
|
|
3118
|
+
width: 80,
|
|
3119
|
+
height: 40
|
|
3120
|
+
};
|
|
3121
|
+
const arrangedBox = aabbOf(ctx.nodeBounds.values());
|
|
3122
|
+
let originX = 64;
|
|
3123
|
+
let originY = 64;
|
|
3124
|
+
if (arrangedBox) if (direction === "TD" || direction === "BT") {
|
|
3125
|
+
originX = arrangedBox.x;
|
|
3126
|
+
originY = side === "before" ? arrangedBox.y - packedBox.height - DEFAULT_RESIDUAL_GAP : arrangedBox.y + arrangedBox.height + DEFAULT_RESIDUAL_GAP;
|
|
3127
|
+
} else {
|
|
3128
|
+
originY = arrangedBox.y;
|
|
3129
|
+
originX = side === "before" ? arrangedBox.x - packedBox.width - DEFAULT_RESIDUAL_GAP : arrangedBox.x + arrangedBox.width + DEFAULT_RESIDUAL_GAP;
|
|
3130
|
+
}
|
|
3131
|
+
for (const [id, b] of packed) ctx.nodeBounds.set(id, {
|
|
3132
|
+
...b,
|
|
3133
|
+
x: b.x - packedBox.x + originX,
|
|
3134
|
+
y: b.y - packedBox.y + originY
|
|
3135
|
+
});
|
|
3136
|
+
};
|
|
3137
|
+
placePack(sources, "before");
|
|
3138
|
+
placePack([...sinks, ...other], "after");
|
|
3139
|
+
const allBox = aabbOf(ctx.nodeBounds.values());
|
|
3140
|
+
if (allBox && (allBox.x < 64 || allBox.y < 64)) {
|
|
3141
|
+
const dx = Math.max(0, 64 - allBox.x);
|
|
3142
|
+
const dy = Math.max(0, 64 - allBox.y);
|
|
3143
|
+
if (dx || dy) {
|
|
3144
|
+
for (const [id, b] of ctx.nodeBounds) ctx.nodeBounds.set(id, {
|
|
3145
|
+
...b,
|
|
3146
|
+
x: b.x + dx,
|
|
3147
|
+
y: b.y + dy
|
|
3148
|
+
});
|
|
3149
|
+
for (const [id, b] of ctx.groupBounds) ctx.groupBounds.set(id, {
|
|
3150
|
+
...b,
|
|
3151
|
+
x: b.x + dx,
|
|
3152
|
+
y: b.y + dy
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
const elkGraph = buildFixedElkGraph(graph, ctx.nodeBounds, ctx.groupBounds, {
|
|
3158
|
+
...options,
|
|
3159
|
+
direction
|
|
3160
|
+
});
|
|
3161
|
+
const laid = await getElk().layout(elkGraph);
|
|
3162
|
+
const laidOutNodes = ranksFromFixed([...ctx.nodeBounds.entries()].map(([nodeId, bounds]) => ({
|
|
3163
|
+
nodeId,
|
|
3164
|
+
bounds,
|
|
3165
|
+
rank: 0,
|
|
3166
|
+
order: 0
|
|
3167
|
+
})), direction);
|
|
3168
|
+
const groups = [];
|
|
3169
|
+
for (const g of graph.groups) {
|
|
3170
|
+
const bounds = ctx.groupBounds.get(g.id);
|
|
3171
|
+
if (!bounds) continue;
|
|
3172
|
+
const padding = paddingForGroup(g);
|
|
3173
|
+
groups.push({
|
|
3174
|
+
groupId: g.id,
|
|
3175
|
+
bounds,
|
|
3176
|
+
labelBox: measureGroupLabelBox(g.label, bounds, Boolean(g.icon && g.icon !== "none" && g.chrome !== false)),
|
|
3177
|
+
padding
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
const rawPaths = [];
|
|
3181
|
+
for (const edge of laid.edges ?? []) {
|
|
3182
|
+
const points = [];
|
|
3183
|
+
for (const section of edge.sections ?? []) {
|
|
3184
|
+
points.push({ ...section.startPoint });
|
|
3185
|
+
for (const bend of section.bendPoints ?? []) points.push({ ...bend });
|
|
3186
|
+
points.push({ ...section.endPoint });
|
|
3187
|
+
}
|
|
3188
|
+
if (points.length >= 2) rawPaths.push({
|
|
3189
|
+
edgeId: edge.id,
|
|
3190
|
+
points
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
const walkEdges = (node) => {
|
|
3194
|
+
for (const edge of node.edges ?? []) {
|
|
3195
|
+
if (rawPaths.some((p) => p.edgeId === edge.id)) continue;
|
|
3196
|
+
const points = [];
|
|
3197
|
+
for (const section of edge.sections ?? []) {
|
|
3198
|
+
points.push({
|
|
3199
|
+
x: (section.startPoint.x ?? 0) + (node.x ?? 0),
|
|
3200
|
+
y: (section.startPoint.y ?? 0) + (node.y ?? 0)
|
|
3201
|
+
});
|
|
3202
|
+
for (const bend of section.bendPoints ?? []) points.push({
|
|
3203
|
+
x: bend.x + (node.x ?? 0),
|
|
3204
|
+
y: bend.y + (node.y ?? 0)
|
|
3205
|
+
});
|
|
3206
|
+
points.push({
|
|
3207
|
+
x: (section.endPoint.x ?? 0) + (node.x ?? 0),
|
|
3208
|
+
y: (section.endPoint.y ?? 0) + (node.y ?? 0)
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
if (points.length >= 2) rawPaths.push({
|
|
3212
|
+
edgeId: edge.id,
|
|
3213
|
+
points
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
for (const c of node.children ?? []) walkEdges(c);
|
|
3217
|
+
};
|
|
3218
|
+
walkEdges(laid);
|
|
3219
|
+
const edgePaths = snapEdgeEndpointsToGeometry(graph, laidOutNodes, rawPaths).map((path) => ({
|
|
3220
|
+
...path,
|
|
3221
|
+
points: ensureOrthogonalPoints(collapseColinearPoints(path.points))
|
|
3222
|
+
}));
|
|
3223
|
+
const edgeLabels = [];
|
|
3224
|
+
let maxX = 0;
|
|
3225
|
+
let maxY = 0;
|
|
3226
|
+
for (const n of laidOutNodes) {
|
|
3227
|
+
maxX = Math.max(maxX, n.bounds.x + n.bounds.width);
|
|
3228
|
+
maxY = Math.max(maxY, n.bounds.y + n.bounds.height);
|
|
3229
|
+
}
|
|
3230
|
+
for (const g of groups) {
|
|
3231
|
+
maxX = Math.max(maxX, g.bounds.x + g.bounds.width);
|
|
3232
|
+
maxY = Math.max(maxY, g.bounds.y + g.bounds.height);
|
|
3233
|
+
}
|
|
3234
|
+
for (const path of edgePaths) for (const p of path.points) {
|
|
3235
|
+
maxX = Math.max(maxX, p.x);
|
|
3236
|
+
maxY = Math.max(maxY, p.y);
|
|
3237
|
+
}
|
|
3238
|
+
return {
|
|
3239
|
+
layout: {
|
|
3240
|
+
nodes: laidOutNodes,
|
|
3241
|
+
groups,
|
|
3242
|
+
edgePaths,
|
|
3243
|
+
edgeLabels,
|
|
3244
|
+
direction,
|
|
3245
|
+
algorithmVersion: ELK_LAYOUT_ALGORITHM,
|
|
3246
|
+
layoutMs: performance.now() - t0,
|
|
3247
|
+
width: maxX + 8,
|
|
3248
|
+
height: maxY + 8
|
|
3249
|
+
},
|
|
3250
|
+
edges: edgePaths,
|
|
3251
|
+
routerAlgorithm: ELK_ROUTER_ALGORITHM
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
const DEFAULT_RESIDUAL_GAP = 48;
|
|
3255
|
+
//#endregion
|
|
3256
|
+
//#region src/layout/sequence/layout-sequence.ts
|
|
3257
|
+
const SEQUENCE_LAYOUT_ALGORITHM = "sequence-v1";
|
|
3258
|
+
const SEQUENCE_ROUTER_ALGORITHM = "sequence-direct-v1";
|
|
3259
|
+
const HEADER_GAP = 32;
|
|
3260
|
+
const PARTICIPANT_GAP = 48;
|
|
3261
|
+
/** Vertical pitch per message/note/divider order — room for label above the path. */
|
|
3262
|
+
const SLOT_H = 52;
|
|
3263
|
+
const LABEL_GAP = 14;
|
|
3264
|
+
const FRAGMENT_PAD_X = 14;
|
|
3265
|
+
const ACTIVATION_W = 10;
|
|
3266
|
+
const SELF_LOOP_W = 44;
|
|
3267
|
+
const SELF_LOOP_EXTRA = 22;
|
|
3268
|
+
const MARGIN = 24;
|
|
3269
|
+
const NOTE_PAD = 10;
|
|
3270
|
+
const NOTE_LINE = 15;
|
|
3271
|
+
const NOTE_BAND_GAP = 12;
|
|
3272
|
+
const DIVIDER_BAND = 36;
|
|
3273
|
+
function densityScale(density) {
|
|
3274
|
+
if (density === "compact") return .88;
|
|
3275
|
+
if (density === "spacious") return 1.22;
|
|
3276
|
+
return 1;
|
|
3277
|
+
}
|
|
3278
|
+
/**
|
|
3279
|
+
* Build cumulative Y baselines for each order index.
|
|
3280
|
+
* Notes, dividers, self-messages, and fragment edges reserve extra vertical band
|
|
3281
|
+
* so labels/boxes don't collide with neighboring message paths.
|
|
3282
|
+
*/
|
|
3283
|
+
function buildOrderBaselines(seq, contentTop, slotH) {
|
|
3284
|
+
const maxOrder = Math.max(0, ...seq.messages.map((m) => m.order), ...seq.notes.map((n) => n.order), ...seq.dividers.map((d) => d.order), ...seq.activations.map((a) => a.endOrder));
|
|
3285
|
+
const noteAt = new Map(seq.notes.map((n) => [n.order, n]));
|
|
3286
|
+
const dividerAt = new Map(seq.dividers.map((d) => [d.order, d]));
|
|
3287
|
+
const selfAt = new Set(seq.messages.filter((m) => m.from && m.to && m.from === m.to).map((m) => m.order));
|
|
3288
|
+
const fragStart = /* @__PURE__ */ new Set();
|
|
3289
|
+
const fragEnd = /* @__PURE__ */ new Set();
|
|
3290
|
+
const operandSepBefore = /* @__PURE__ */ new Set();
|
|
3291
|
+
const walkFrags = (fragments) => {
|
|
3292
|
+
for (const frag of fragments) {
|
|
3293
|
+
fragStart.add(frag.startOrder);
|
|
3294
|
+
fragEnd.add(frag.endOrder);
|
|
3295
|
+
for (let i = 1; i < frag.operands.length; i++) operandSepBefore.add(frag.operands[i].startOrder);
|
|
3296
|
+
for (const op of frag.operands) walkFrags(op.children);
|
|
3297
|
+
}
|
|
3298
|
+
};
|
|
3299
|
+
walkFrags(seq.fragments);
|
|
3300
|
+
const yBaselines = /* @__PURE__ */ new Map();
|
|
3301
|
+
const tops = /* @__PURE__ */ new Map();
|
|
3302
|
+
const bottoms = /* @__PURE__ */ new Map();
|
|
3303
|
+
let cursor = contentTop;
|
|
3304
|
+
for (let order = 0; order <= maxOrder; order++) {
|
|
3305
|
+
let topPad = 0;
|
|
3306
|
+
let band = slotH;
|
|
3307
|
+
let bottomPad = 0;
|
|
3308
|
+
if (fragStart.has(order)) topPad += 46;
|
|
3309
|
+
if (operandSepBefore.has(order)) topPad += 18;
|
|
3310
|
+
const note = noteAt.get(order);
|
|
3311
|
+
if (note) band = Math.max(band, measureNoteHeight(note.text) + NOTE_BAND_GAP);
|
|
3312
|
+
if (dividerAt.has(order)) band = Math.max(band, DIVIDER_BAND);
|
|
3313
|
+
if (selfAt.has(order)) band = Math.max(band, slotH + SELF_LOOP_EXTRA);
|
|
3314
|
+
if (fragEnd.has(order)) bottomPad += 24;
|
|
3315
|
+
const top = cursor;
|
|
3316
|
+
const center = cursor + topPad + band / 2;
|
|
3317
|
+
const bottom = cursor + topPad + band + bottomPad;
|
|
3318
|
+
tops.set(order, top);
|
|
3319
|
+
yBaselines.set(order, center);
|
|
3320
|
+
bottoms.set(order, bottom);
|
|
3321
|
+
cursor = bottom;
|
|
3322
|
+
}
|
|
3323
|
+
const fallback = (order) => contentTop + order * slotH;
|
|
3324
|
+
return {
|
|
3325
|
+
yAt: (order) => yBaselines.get(order) ?? fallback(order) + slotH / 2,
|
|
3326
|
+
bandTop: (order) => tops.get(order) ?? fallback(order),
|
|
3327
|
+
bandBottom: (order) => bottoms.get(order) ?? fallback(order) + slotH,
|
|
3328
|
+
bottomY: cursor + MARGIN
|
|
3329
|
+
};
|
|
3330
|
+
}
|
|
3331
|
+
function measureNoteHeight(text) {
|
|
3332
|
+
return Math.max(1, Math.ceil(text.length / 32)) * NOTE_LINE + NOTE_PAD * 2;
|
|
3333
|
+
}
|
|
3334
|
+
/**
|
|
3335
|
+
* Time-axis sequence layout: participants on X, messages ordered on Y.
|
|
3336
|
+
*/
|
|
3337
|
+
function layoutSequence(graph, measured, options = {}) {
|
|
3338
|
+
const t0 = performance.now();
|
|
3339
|
+
const seq = graph.sequence;
|
|
3340
|
+
if (!seq) return emptySequenceResult(t0);
|
|
3341
|
+
const scale = densityScale(options.density);
|
|
3342
|
+
const slotH = SLOT_H * scale;
|
|
3343
|
+
const participantGap = PARTICIPANT_GAP * scale;
|
|
3344
|
+
const measuredById = new Map(measured.map((m) => [m.nodeId, m]));
|
|
3345
|
+
const nodes = [];
|
|
3346
|
+
const centers = /* @__PURE__ */ new Map();
|
|
3347
|
+
let x = MARGIN;
|
|
3348
|
+
let headerH = 40;
|
|
3349
|
+
for (const [i, id] of seq.participantOrder.entries()) {
|
|
3350
|
+
const m = measuredById.get(id);
|
|
3351
|
+
const w = m?.width ?? 120;
|
|
3352
|
+
const h = m?.height ?? 40;
|
|
3353
|
+
headerH = Math.max(headerH, h);
|
|
3354
|
+
const bounds = {
|
|
3355
|
+
x,
|
|
3356
|
+
y: MARGIN,
|
|
3357
|
+
width: w,
|
|
3358
|
+
height: h
|
|
3359
|
+
};
|
|
3360
|
+
nodes.push({
|
|
3361
|
+
nodeId: id,
|
|
3362
|
+
bounds,
|
|
3363
|
+
rank: 0,
|
|
3364
|
+
order: i
|
|
3365
|
+
});
|
|
3366
|
+
centers.set(id, x + w / 2);
|
|
3367
|
+
x += w + participantGap;
|
|
3368
|
+
}
|
|
3369
|
+
const { yAt, bandTop, bandBottom, bottomY } = buildOrderBaselines(seq, MARGIN + headerH + HEADER_GAP * scale, slotH);
|
|
3370
|
+
const messages = [];
|
|
3371
|
+
const edgePaths = [];
|
|
3372
|
+
const edgeLabels = [];
|
|
3373
|
+
let msgNumber = 0;
|
|
3374
|
+
for (const msg of seq.messages) {
|
|
3375
|
+
if (seq.autonumber && msg.kind !== "destroy") msgNumber += 1;
|
|
3376
|
+
const y = yAt(msg.order);
|
|
3377
|
+
const fromX = msg.from && centers.has(msg.from) ? centers.get(msg.from) : msg.kind === "found" ? (centers.get(msg.to) ?? MARGIN) - 40 : MARGIN;
|
|
3378
|
+
const toX = msg.to && centers.has(msg.to) ? centers.get(msg.to) : msg.kind === "lost" ? (centers.get(msg.from) ?? MARGIN) + 40 : fromX;
|
|
3379
|
+
let points;
|
|
3380
|
+
if (msg.kind === "destroy") {
|
|
3381
|
+
const cx = centers.get(msg.from) ?? fromX;
|
|
3382
|
+
points = [
|
|
3383
|
+
{
|
|
3384
|
+
x: cx - 8,
|
|
3385
|
+
y: y - 8
|
|
3386
|
+
},
|
|
3387
|
+
{
|
|
3388
|
+
x: cx + 8,
|
|
3389
|
+
y: y + 8
|
|
3390
|
+
},
|
|
3391
|
+
{
|
|
3392
|
+
x: cx + 8,
|
|
3393
|
+
y: y - 8
|
|
3394
|
+
},
|
|
3395
|
+
{
|
|
3396
|
+
x: cx - 8,
|
|
3397
|
+
y: y + 8
|
|
3398
|
+
}
|
|
3399
|
+
];
|
|
3400
|
+
} else if (msg.from && msg.to && msg.from === msg.to) {
|
|
3401
|
+
const cx = centers.get(msg.from);
|
|
3402
|
+
const loopDepth = Math.max(18, SELF_LOOP_EXTRA * scale);
|
|
3403
|
+
points = [
|
|
3404
|
+
{
|
|
3405
|
+
x: cx,
|
|
3406
|
+
y: y - loopDepth * .15
|
|
3407
|
+
},
|
|
3408
|
+
{
|
|
3409
|
+
x: cx + SELF_LOOP_W,
|
|
3410
|
+
y: y - loopDepth * .15
|
|
3411
|
+
},
|
|
3412
|
+
{
|
|
3413
|
+
x: cx + SELF_LOOP_W,
|
|
3414
|
+
y: y + loopDepth * .85
|
|
3415
|
+
},
|
|
3416
|
+
{
|
|
3417
|
+
x: cx,
|
|
3418
|
+
y: y + loopDepth * .85
|
|
3419
|
+
}
|
|
3420
|
+
];
|
|
3421
|
+
} else points = [{
|
|
3422
|
+
x: fromX,
|
|
3423
|
+
y
|
|
3424
|
+
}, {
|
|
3425
|
+
x: toX,
|
|
3426
|
+
y
|
|
3427
|
+
}];
|
|
3428
|
+
const labelCenter = points.length >= 2 ? {
|
|
3429
|
+
x: (points[0].x + points[points.length - 1].x) / 2,
|
|
3430
|
+
y: Math.min(points[0].y, points[points.length - 1].y) - LABEL_GAP
|
|
3431
|
+
} : void 0;
|
|
3432
|
+
messages.push({
|
|
3433
|
+
messageId: msg.id,
|
|
3434
|
+
kind: msg.kind,
|
|
3435
|
+
points,
|
|
3436
|
+
label: msg.label,
|
|
3437
|
+
labelCenter,
|
|
3438
|
+
number: seq.autonumber && msg.kind !== "destroy" ? msgNumber : void 0
|
|
3439
|
+
});
|
|
3440
|
+
if (msg.from && msg.to && msg.from !== "*" && msg.to !== "*") edgePaths.push({
|
|
3441
|
+
edgeId: msg.id,
|
|
3442
|
+
points
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
const lifelines = [];
|
|
3446
|
+
for (const id of seq.participantOrder) {
|
|
3447
|
+
const cx = centers.get(id);
|
|
3448
|
+
const node = nodes.find((n) => n.nodeId === id);
|
|
3449
|
+
lifelines.push({
|
|
3450
|
+
participantId: id,
|
|
3451
|
+
x: cx,
|
|
3452
|
+
y0: node.bounds.y + node.bounds.height,
|
|
3453
|
+
y1: bottomY
|
|
3454
|
+
});
|
|
3455
|
+
}
|
|
3456
|
+
const activations = seq.activations.map((a) => {
|
|
3457
|
+
const cx = centers.get(a.participantId) ?? MARGIN;
|
|
3458
|
+
const y0 = yAt(a.startOrder) - 6;
|
|
3459
|
+
const y1 = yAt(a.endOrder) + 6;
|
|
3460
|
+
return {
|
|
3461
|
+
id: a.id,
|
|
3462
|
+
participantId: a.participantId,
|
|
3463
|
+
startOrder: a.startOrder,
|
|
3464
|
+
endOrder: a.endOrder,
|
|
3465
|
+
bounds: {
|
|
3466
|
+
x: cx - ACTIVATION_W / 2,
|
|
3467
|
+
y: Math.min(y0, y1),
|
|
3468
|
+
width: ACTIVATION_W,
|
|
3469
|
+
height: Math.max(8, Math.abs(y1 - y0))
|
|
3470
|
+
}
|
|
3471
|
+
};
|
|
3472
|
+
});
|
|
3473
|
+
const fragments = [];
|
|
3474
|
+
const layoutFrag = (frag, depth) => {
|
|
3475
|
+
const xs = seq.participantOrder.map((id) => centers.get(id));
|
|
3476
|
+
const minX = Math.min(...xs) - FRAGMENT_PAD_X - depth * 6;
|
|
3477
|
+
const maxX = Math.max(...xs) + FRAGMENT_PAD_X + depth * 6;
|
|
3478
|
+
const y0 = bandTop(frag.startOrder) + 2;
|
|
3479
|
+
const y1 = bandBottom(frag.endOrder) - 2;
|
|
3480
|
+
const separators = [];
|
|
3481
|
+
const operandLabels = [];
|
|
3482
|
+
const operandBands = [];
|
|
3483
|
+
for (let i = 0; i < frag.operands.length; i++) {
|
|
3484
|
+
const op = frag.operands[i];
|
|
3485
|
+
const opTop = i === 0 ? y0 : bandTop(op.startOrder);
|
|
3486
|
+
const opBottom = i === frag.operands.length - 1 ? y1 : bandTop(frag.operands[i + 1].startOrder);
|
|
3487
|
+
operandBands.push({
|
|
3488
|
+
startOrder: op.startOrder,
|
|
3489
|
+
endOrder: op.endOrder,
|
|
3490
|
+
styleRefs: op.styleRefs,
|
|
3491
|
+
bounds: {
|
|
3492
|
+
x: minX,
|
|
3493
|
+
y: opTop,
|
|
3494
|
+
width: maxX - minX,
|
|
3495
|
+
height: Math.max(8, opBottom - opTop)
|
|
3496
|
+
}
|
|
3497
|
+
});
|
|
3498
|
+
if (i > 0) {
|
|
3499
|
+
separators.push(opTop);
|
|
3500
|
+
if (op.label) operandLabels.push({
|
|
3501
|
+
text: op.label,
|
|
3502
|
+
x: minX + 8,
|
|
3503
|
+
y: opTop + 4
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
if (frag.label) operandLabels.unshift({
|
|
3508
|
+
text: `${sequenceFragmentDisplayName(frag.operator)} [${frag.label}]`,
|
|
3509
|
+
x: minX + 8,
|
|
3510
|
+
y: y0 + 6
|
|
3511
|
+
});
|
|
3512
|
+
else operandLabels.unshift({
|
|
3513
|
+
text: sequenceFragmentDisplayName(frag.operator),
|
|
3514
|
+
x: minX + 8,
|
|
3515
|
+
y: y0 + 6
|
|
3516
|
+
});
|
|
3517
|
+
fragments.push({
|
|
3518
|
+
id: frag.id,
|
|
3519
|
+
operator: frag.operator,
|
|
3520
|
+
label: frag.label,
|
|
3521
|
+
styleRefs: frag.styleRefs,
|
|
3522
|
+
unresolvedVars: frag.unresolvedVars,
|
|
3523
|
+
bounds: {
|
|
3524
|
+
x: minX,
|
|
3525
|
+
y: y0,
|
|
3526
|
+
width: maxX - minX,
|
|
3527
|
+
height: Math.max(24, y1 - y0)
|
|
3528
|
+
},
|
|
3529
|
+
startOrder: frag.startOrder,
|
|
3530
|
+
endOrder: frag.endOrder,
|
|
3531
|
+
separators,
|
|
3532
|
+
operandLabels,
|
|
3533
|
+
operandBands
|
|
3534
|
+
});
|
|
3535
|
+
for (const op of frag.operands) for (const child of op.children) layoutFrag(child, depth + 1);
|
|
3536
|
+
};
|
|
3537
|
+
for (const frag of seq.fragments) layoutFrag(frag, 0);
|
|
3538
|
+
const notes = seq.notes.map((n) => {
|
|
3539
|
+
const y = yAt(n.order);
|
|
3540
|
+
const h = measureNoteHeight(n.text);
|
|
3541
|
+
const ids = n.participantIds.filter((id) => centers.has(id));
|
|
3542
|
+
let x0;
|
|
3543
|
+
let x1;
|
|
3544
|
+
if (n.placement === "over" && ids.length) {
|
|
3545
|
+
const xs = ids.map((id) => centers.get(id));
|
|
3546
|
+
x0 = Math.min(...xs) - 24;
|
|
3547
|
+
x1 = Math.max(...xs) + 24;
|
|
3548
|
+
} else if (n.placement === "left" && ids[0]) {
|
|
3549
|
+
const cx = centers.get(ids[0]);
|
|
3550
|
+
x0 = cx - 150;
|
|
3551
|
+
x1 = cx - 24;
|
|
3552
|
+
} else if (ids[0]) {
|
|
3553
|
+
const cx = centers.get(ids[0]);
|
|
3554
|
+
x0 = cx + 24;
|
|
3555
|
+
x1 = cx + 150;
|
|
3556
|
+
} else {
|
|
3557
|
+
x0 = MARGIN;
|
|
3558
|
+
x1 = 144;
|
|
3559
|
+
}
|
|
3560
|
+
const w = Math.max(80, x1 - x0);
|
|
3561
|
+
return {
|
|
3562
|
+
id: n.id,
|
|
3563
|
+
text: n.text,
|
|
3564
|
+
bounds: {
|
|
3565
|
+
x: x0,
|
|
3566
|
+
y: y - h / 2,
|
|
3567
|
+
width: w,
|
|
3568
|
+
height: h
|
|
3569
|
+
}
|
|
3570
|
+
};
|
|
3571
|
+
});
|
|
3572
|
+
const firstX = Math.min(...centers.values(), MARGIN);
|
|
3573
|
+
const lastX = Math.max(...centers.values(), MARGIN);
|
|
3574
|
+
const sequence = {
|
|
3575
|
+
lifelines,
|
|
3576
|
+
activations,
|
|
3577
|
+
fragments,
|
|
3578
|
+
notes,
|
|
3579
|
+
dividers: seq.dividers.map((d) => ({
|
|
3580
|
+
id: d.id,
|
|
3581
|
+
y: yAt(d.order) + 4,
|
|
3582
|
+
x0: firstX - 20,
|
|
3583
|
+
x1: lastX + 20,
|
|
3584
|
+
label: d.label
|
|
3585
|
+
})),
|
|
3586
|
+
messages
|
|
3587
|
+
};
|
|
3588
|
+
let maxX = x;
|
|
3589
|
+
let maxY = bottomY;
|
|
3590
|
+
for (const f of fragments) {
|
|
3591
|
+
maxX = Math.max(maxX, f.bounds.x + f.bounds.width);
|
|
3592
|
+
maxY = Math.max(maxY, f.bounds.y + f.bounds.height);
|
|
3593
|
+
}
|
|
3594
|
+
for (const n of notes) {
|
|
3595
|
+
maxX = Math.max(maxX, n.bounds.x + n.bounds.width);
|
|
3596
|
+
maxY = Math.max(maxY, n.bounds.y + n.bounds.height);
|
|
3597
|
+
}
|
|
3598
|
+
for (const path of edgePaths) for (const p of path.points) {
|
|
3599
|
+
maxX = Math.max(maxX, p.x);
|
|
3600
|
+
maxY = Math.max(maxY, p.y);
|
|
3601
|
+
}
|
|
3602
|
+
return {
|
|
3603
|
+
layout: {
|
|
3604
|
+
nodes,
|
|
3605
|
+
groups: [],
|
|
3606
|
+
edgePaths,
|
|
3607
|
+
edgeLabels,
|
|
3608
|
+
direction: "TD",
|
|
3609
|
+
algorithmVersion: SEQUENCE_LAYOUT_ALGORITHM,
|
|
3610
|
+
layoutMs: performance.now() - t0,
|
|
3611
|
+
width: maxX + MARGIN,
|
|
3612
|
+
height: maxY + MARGIN,
|
|
3613
|
+
sequence
|
|
3614
|
+
},
|
|
3615
|
+
edges: edgePaths,
|
|
3616
|
+
routerAlgorithm: SEQUENCE_ROUTER_ALGORITHM
|
|
3617
|
+
};
|
|
3618
|
+
}
|
|
3619
|
+
function emptySequenceResult(t0) {
|
|
3620
|
+
return {
|
|
3621
|
+
layout: {
|
|
3622
|
+
nodes: [],
|
|
3623
|
+
groups: [],
|
|
3624
|
+
edgePaths: [],
|
|
3625
|
+
edgeLabels: [],
|
|
3626
|
+
direction: "TD",
|
|
3627
|
+
algorithmVersion: SEQUENCE_LAYOUT_ALGORITHM,
|
|
3628
|
+
layoutMs: performance.now() - t0,
|
|
3629
|
+
width: 0,
|
|
3630
|
+
height: 0
|
|
3631
|
+
},
|
|
3632
|
+
edges: [],
|
|
3633
|
+
routerAlgorithm: SEQUENCE_ROUTER_ALGORITHM
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
3636
|
+
function isSequenceGraph(graph) {
|
|
3637
|
+
return graph.diagramKind === "sequence" || graph.sequence != null;
|
|
3638
|
+
}
|
|
3639
|
+
//#endregion
|
|
3640
|
+
//#region src/layout/elk/layout-with-elk.ts
|
|
3641
|
+
function collectAbsoluteNodes(node, offsetX, offsetY, parentId, boundsOut, index) {
|
|
3642
|
+
const x = offsetX + (node.x ?? 0);
|
|
3643
|
+
const y = offsetY + (node.y ?? 0);
|
|
3644
|
+
const bounds = {
|
|
3645
|
+
x,
|
|
3646
|
+
y,
|
|
3647
|
+
width: node.width ?? 0,
|
|
3648
|
+
height: node.height ?? 0
|
|
3649
|
+
};
|
|
3650
|
+
index.set(node.id, {
|
|
3651
|
+
bounds,
|
|
3652
|
+
parentId
|
|
3653
|
+
});
|
|
3654
|
+
if (node.id !== "root") boundsOut.set(node.id, bounds);
|
|
3655
|
+
for (const child of node.children ?? []) collectAbsoluteNodes(child, x, y, node.id, boundsOut, index);
|
|
3656
|
+
}
|
|
3657
|
+
/**
|
|
3658
|
+
* With INCLUDE_CHILDREN, ELK may list edges on the root while section/label
|
|
3659
|
+
* coordinates stay relative to the LCA of the endpoints (often a compound group).
|
|
3660
|
+
* Offset by that LCA's absolute origin — not the edges-array owner.
|
|
3661
|
+
*/
|
|
3662
|
+
function edgeCoordinateOrigin(index, sourceId, targetId) {
|
|
3663
|
+
if (!sourceId || !targetId) return {
|
|
3664
|
+
x: 0,
|
|
3665
|
+
y: 0
|
|
3666
|
+
};
|
|
3667
|
+
const sourceNode = endpointNodeId(sourceId);
|
|
3668
|
+
const targetNode = endpointNodeId(targetId);
|
|
3669
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
3670
|
+
let cur = sourceNode;
|
|
3671
|
+
while (cur) {
|
|
3672
|
+
ancestors.add(cur);
|
|
3673
|
+
cur = index.get(cur)?.parentId ?? null;
|
|
3674
|
+
}
|
|
3675
|
+
cur = targetNode;
|
|
3676
|
+
while (cur) {
|
|
3677
|
+
if (ancestors.has(cur)) {
|
|
3678
|
+
const hit = index.get(cur);
|
|
3679
|
+
return hit ? {
|
|
3680
|
+
x: hit.bounds.x,
|
|
3681
|
+
y: hit.bounds.y
|
|
3682
|
+
} : {
|
|
3683
|
+
x: 0,
|
|
3684
|
+
y: 0
|
|
3685
|
+
};
|
|
3686
|
+
}
|
|
3687
|
+
cur = index.get(cur)?.parentId ?? null;
|
|
3688
|
+
}
|
|
3689
|
+
return {
|
|
3690
|
+
x: 0,
|
|
3691
|
+
y: 0
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3694
|
+
function collectAbsoluteEdges(node, index, paths, labels, seen) {
|
|
3695
|
+
for (const edge of node.edges ?? []) {
|
|
3696
|
+
if (seen.has(edge.id)) continue;
|
|
3697
|
+
seen.add(edge.id);
|
|
3698
|
+
const origin = edgeCoordinateOrigin(index, edge.sources?.[0], edge.targets?.[0]);
|
|
3699
|
+
const points = edgePoints(edge, origin.x, origin.y);
|
|
3700
|
+
if (points.length >= 2) paths.push({
|
|
3701
|
+
edgeId: edge.id,
|
|
3702
|
+
points
|
|
3703
|
+
});
|
|
3704
|
+
for (const label of edge.labels ?? []) {
|
|
3705
|
+
if (label.x == null || label.y == null || !label.width || !label.height) continue;
|
|
3706
|
+
const bounds = {
|
|
3707
|
+
x: origin.x + label.x,
|
|
3708
|
+
y: origin.y + label.y,
|
|
3709
|
+
width: label.width,
|
|
3710
|
+
height: label.height
|
|
3711
|
+
};
|
|
3712
|
+
labels.push({
|
|
3713
|
+
edgeId: edge.id,
|
|
3714
|
+
text: label.text,
|
|
3715
|
+
bounds,
|
|
3716
|
+
anchor: {
|
|
3717
|
+
x: bounds.x + bounds.width / 2,
|
|
3718
|
+
y: bounds.y + bounds.height / 2
|
|
3719
|
+
}
|
|
3720
|
+
});
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
for (const child of node.children ?? []) collectAbsoluteEdges(child, index, paths, labels, seen);
|
|
3724
|
+
}
|
|
3725
|
+
function edgePoints(edge, offsetX, offsetY) {
|
|
3726
|
+
const points = [];
|
|
3727
|
+
for (const section of edge.sections ?? []) {
|
|
3728
|
+
points.push({
|
|
3729
|
+
x: section.startPoint.x + offsetX,
|
|
3730
|
+
y: section.startPoint.y + offsetY
|
|
3731
|
+
});
|
|
3732
|
+
for (const bend of section.bendPoints ?? []) points.push({
|
|
3733
|
+
x: bend.x + offsetX,
|
|
3734
|
+
y: bend.y + offsetY
|
|
3735
|
+
});
|
|
3736
|
+
points.push({
|
|
3737
|
+
x: section.endPoint.x + offsetX,
|
|
3738
|
+
y: section.endPoint.y + offsetY
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
return dedupePoints(points);
|
|
3742
|
+
}
|
|
3743
|
+
function dedupePoints(points) {
|
|
3744
|
+
const out = [];
|
|
3745
|
+
for (const p of points) {
|
|
3746
|
+
const prev = out[out.length - 1];
|
|
3747
|
+
if (prev && Math.abs(prev.x - p.x) < .5 && Math.abs(prev.y - p.y) < .5) continue;
|
|
3748
|
+
out.push(p);
|
|
3749
|
+
}
|
|
3750
|
+
return out;
|
|
3751
|
+
}
|
|
3752
|
+
function ranksFromBounds(nodes, direction) {
|
|
3753
|
+
const dir = direction ?? "LR";
|
|
3754
|
+
const horizontal = dir === "LR" || dir === "RL";
|
|
3755
|
+
const sorted = [...nodes].sort((a, b) => {
|
|
3756
|
+
const aPrimary = horizontal ? a.bounds.x : a.bounds.y;
|
|
3757
|
+
const bPrimary = horizontal ? b.bounds.x : b.bounds.y;
|
|
3758
|
+
if (aPrimary !== bPrimary) return aPrimary - bPrimary;
|
|
3759
|
+
return (horizontal ? a.bounds.y : a.bounds.x) - (horizontal ? b.bounds.y : b.bounds.x);
|
|
3760
|
+
});
|
|
3761
|
+
const rankOf = /* @__PURE__ */ new Map();
|
|
3762
|
+
let rank = 0;
|
|
3763
|
+
let lastPrimary = Number.NEGATIVE_INFINITY;
|
|
3764
|
+
for (const n of sorted) {
|
|
3765
|
+
const primary = horizontal ? n.bounds.x : n.bounds.y;
|
|
3766
|
+
if (primary - lastPrimary > 8) {
|
|
3767
|
+
if (lastPrimary !== Number.NEGATIVE_INFINITY) rank += 1;
|
|
3768
|
+
lastPrimary = primary;
|
|
3769
|
+
}
|
|
3770
|
+
rankOf.set(n.nodeId, rank);
|
|
3771
|
+
}
|
|
3772
|
+
const orderInRank = /* @__PURE__ */ new Map();
|
|
3773
|
+
return sorted.map((n) => {
|
|
3774
|
+
const r = rankOf.get(n.nodeId) ?? 0;
|
|
3775
|
+
const order = orderInRank.get(r) ?? 0;
|
|
3776
|
+
orderInRank.set(r, order + 1);
|
|
3777
|
+
return {
|
|
3778
|
+
nodeId: n.nodeId,
|
|
3779
|
+
bounds: n.bounds,
|
|
3780
|
+
rank: r,
|
|
3781
|
+
order
|
|
3782
|
+
};
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
function groupsFromElk(graph, absolute, laidOutNodes) {
|
|
3786
|
+
const fallback = computeGroupBounds(graph, laidOutNodes);
|
|
3787
|
+
const byId = new Map(fallback.map((g) => [g.groupId, g]));
|
|
3788
|
+
for (const group of graph.groups) {
|
|
3789
|
+
const elkBounds = absolute.get(`group:${group.id}`);
|
|
3790
|
+
if (!elkBounds || elkBounds.width <= 0 || elkBounds.height <= 0) continue;
|
|
3791
|
+
const padding = paddingForGroup(group);
|
|
3792
|
+
byId.set(group.id, {
|
|
3793
|
+
groupId: group.id,
|
|
3794
|
+
bounds: elkBounds,
|
|
3795
|
+
labelBox: measureGroupLabelBox(group.label, elkBounds, Boolean(group.icon && group.icon !== "none" && group.chrome !== false)),
|
|
3796
|
+
padding
|
|
3797
|
+
});
|
|
3798
|
+
}
|
|
3799
|
+
return [...byId.values()];
|
|
3800
|
+
}
|
|
3801
|
+
/**
|
|
3802
|
+
* Layout + orthogonal edge routes via ELK (elkjs API; elk-rs drop-in when published).
|
|
3803
|
+
*/
|
|
3804
|
+
async function layoutAndRouteWithElk(graph, measured, options = {}) {
|
|
3805
|
+
if (isSequenceGraph(graph)) return layoutSequence(graph, measured, options);
|
|
3806
|
+
if (needsRegionArrange(graph, options)) return layoutAndRouteArranged(graph, measured, options);
|
|
3807
|
+
const t0 = performance.now();
|
|
3808
|
+
const direction = options.direction ?? "LR";
|
|
3809
|
+
const elkGraph = buildElkGraph(graph, measured, {
|
|
3810
|
+
...options,
|
|
3811
|
+
direction
|
|
3812
|
+
});
|
|
3813
|
+
const laid = await getElk().layout(elkGraph);
|
|
3814
|
+
const absolute = /* @__PURE__ */ new Map();
|
|
3815
|
+
const index = /* @__PURE__ */ new Map();
|
|
3816
|
+
collectAbsoluteNodes(laid, 0, 0, null, absolute, index);
|
|
3817
|
+
const laidOutNodes = ranksFromBounds(graph.nodes.map((n) => {
|
|
3818
|
+
const b = absolute.get(n.id);
|
|
3819
|
+
if (!b) return null;
|
|
3820
|
+
return {
|
|
3821
|
+
nodeId: n.id,
|
|
3822
|
+
bounds: b
|
|
3823
|
+
};
|
|
3824
|
+
}).filter((n) => n != null), direction);
|
|
3825
|
+
const groups = groupsFromElk(graph, absolute, laidOutNodes);
|
|
3826
|
+
const rawPaths = [];
|
|
3827
|
+
const rawLabels = [];
|
|
3828
|
+
collectAbsoluteEdges(laid, index, rawPaths, rawLabels, /* @__PURE__ */ new Set());
|
|
3829
|
+
const edgePaths = polishEdgePaths(snapEdgeEndpointsToGeometry(graph, laidOutNodes, rawPaths), laidOutNodes.map((n) => n.bounds), Math.max(28, options.edgeNodeSpacing ?? 28));
|
|
3830
|
+
const edgeLabels = rawLabels;
|
|
3831
|
+
let maxX = 0;
|
|
3832
|
+
let maxY = 0;
|
|
3833
|
+
for (const n of laidOutNodes) {
|
|
3834
|
+
maxX = Math.max(maxX, n.bounds.x + n.bounds.width);
|
|
3835
|
+
maxY = Math.max(maxY, n.bounds.y + n.bounds.height);
|
|
3836
|
+
}
|
|
3837
|
+
for (const g of groups) {
|
|
3838
|
+
maxX = Math.max(maxX, g.bounds.x + g.bounds.width);
|
|
3839
|
+
maxY = Math.max(maxY, g.bounds.y + g.bounds.height);
|
|
3840
|
+
}
|
|
3841
|
+
for (const path of edgePaths) for (const p of path.points) {
|
|
3842
|
+
maxX = Math.max(maxX, p.x);
|
|
3843
|
+
maxY = Math.max(maxY, p.y);
|
|
3844
|
+
}
|
|
3845
|
+
for (const label of edgeLabels) {
|
|
3846
|
+
maxX = Math.max(maxX, label.bounds.x + label.bounds.width);
|
|
3847
|
+
maxY = Math.max(maxY, label.bounds.y + label.bounds.height);
|
|
3848
|
+
}
|
|
3849
|
+
return {
|
|
3850
|
+
layout: {
|
|
3851
|
+
nodes: laidOutNodes,
|
|
3852
|
+
groups,
|
|
3853
|
+
edgePaths,
|
|
3854
|
+
edgeLabels,
|
|
3855
|
+
direction,
|
|
3856
|
+
algorithmVersion: ELK_LAYOUT_ALGORITHM,
|
|
3857
|
+
layoutMs: performance.now() - t0,
|
|
3858
|
+
width: Math.max(maxX, laid.width ?? 0) + 8,
|
|
3859
|
+
height: Math.max(maxY, laid.height ?? 0) + 8
|
|
3860
|
+
},
|
|
3861
|
+
edges: edgePaths,
|
|
3862
|
+
routerAlgorithm: ELK_ROUTER_ALGORITHM
|
|
3863
|
+
};
|
|
3864
|
+
}
|
|
3865
|
+
//#endregion
|
|
3866
|
+
//#region src/layout-from-graph.ts
|
|
3867
|
+
const DEFAULT_LAYOUT = {
|
|
3868
|
+
direction: "LR",
|
|
3869
|
+
density: "normal",
|
|
3870
|
+
spacingScale: 1.1,
|
|
3871
|
+
algorithmVersion: "elk-layered-v1",
|
|
3872
|
+
groupLayout: "compound",
|
|
3873
|
+
nodePlacement: "balanced"
|
|
3874
|
+
};
|
|
3875
|
+
async function layoutFromGraph(graph, layoutOpts = {}) {
|
|
3876
|
+
const opts = mergeOptions(DEFAULT_LAYOUT, layoutOpts);
|
|
3877
|
+
return (await layoutAndRouteWithElk(graph, measureGraph(graph).nodes, opts)).layout;
|
|
3878
|
+
}
|
|
3879
|
+
//#endregion
|
|
3880
|
+
//#region src/measure/browser-font.ts
|
|
3881
|
+
/** Load bundled Inter via FontFace so canvas metrics match CLI opentype measurer. */
|
|
3882
|
+
let fontLoadPromise = null;
|
|
3883
|
+
let fontsReady = false;
|
|
3884
|
+
function browserFontsReady() {
|
|
3885
|
+
return fontsReady;
|
|
3886
|
+
}
|
|
3887
|
+
function ensureBrowserFonts(fontUrl) {
|
|
3888
|
+
if (typeof document === "undefined") return Promise.resolve();
|
|
3889
|
+
if (fontsReady) return Promise.resolve();
|
|
3890
|
+
if (fontLoadPromise) return fontLoadPromise;
|
|
3891
|
+
fontLoadPromise = (async () => {
|
|
3892
|
+
const face = new FontFace("Inter", `url(${fontUrl})`, {
|
|
3893
|
+
weight: "500",
|
|
3894
|
+
style: "normal"
|
|
3895
|
+
});
|
|
3896
|
+
await face.load();
|
|
3897
|
+
document.fonts.add(face);
|
|
3898
|
+
await document.fonts.load("500 14px \"Inter\"");
|
|
3899
|
+
fontsReady = true;
|
|
3900
|
+
})();
|
|
3901
|
+
return fontLoadPromise;
|
|
3902
|
+
}
|
|
3903
|
+
//#endregion
|
|
3904
|
+
//#region src/layout/erd-snap.ts
|
|
3905
|
+
const OUTER_CLEARANCE = 18;
|
|
3906
|
+
/**
|
|
3907
|
+
* Snap ERD edge endpoints to column midlines while preserving orthogonal routes.
|
|
3908
|
+
* Runs after ELK; before crossing treatment / trim.
|
|
3909
|
+
*/
|
|
3910
|
+
function snapErdEdgeEndpoints(graph, layout, edgePaths) {
|
|
3911
|
+
const nodeMap = new Map(layout.nodes.map((n) => [n.nodeId, n]));
|
|
3912
|
+
const graphNodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
3913
|
+
const obstacles = layout.nodes.map((n) => ({
|
|
3914
|
+
nodeId: n.nodeId,
|
|
3915
|
+
rect: n.bounds
|
|
3916
|
+
}));
|
|
3917
|
+
return edgePaths.map((path) => {
|
|
3918
|
+
const edge = graph.edges.find((e) => e.id === path.edgeId);
|
|
3919
|
+
if (!edge || !edge.fromColumn && !edge.toColumn) return path;
|
|
3920
|
+
if (path.points.length < 2) return path;
|
|
3921
|
+
const fromNode = graphNodes.get(edge.from);
|
|
3922
|
+
const toNode = graphNodes.get(edge.to);
|
|
3923
|
+
const fromLaid = nodeMap.get(edge.from);
|
|
3924
|
+
const toLaid = nodeMap.get(edge.to);
|
|
3925
|
+
if (!fromNode || !toNode || !fromLaid || !toLaid) return path;
|
|
3926
|
+
let fromY;
|
|
3927
|
+
let toY;
|
|
3928
|
+
if (edge.fromColumn && isErdTableNode(fromNode)) {
|
|
3929
|
+
const row = findColumnIndex(fromNode.columns, edge.fromColumn);
|
|
3930
|
+
if (row >= 0) fromY = columnAnchorY(fromLaid.bounds.y, row, fromNode.scale ?? 1);
|
|
3931
|
+
}
|
|
3932
|
+
if (edge.toColumn && isErdTableNode(toNode)) {
|
|
3933
|
+
const row = findColumnIndex(toNode.columns, edge.toColumn);
|
|
3934
|
+
if (row >= 0) toY = columnAnchorY(toLaid.bounds.y, row, toNode.scale ?? 1);
|
|
3935
|
+
}
|
|
3936
|
+
if (fromY == null && toY == null) return path;
|
|
3937
|
+
const sides = pickAttachmentSides(fromLaid.bounds, toLaid.bounds, path.points);
|
|
3938
|
+
const start = pointOnSide(fromLaid.bounds, sides.fromSide, fromY ?? path.points[0].y);
|
|
3939
|
+
const end = pointOnSide(toLaid.bounds, sides.toSide, toY ?? path.points[path.points.length - 1].y);
|
|
3940
|
+
let points = rebuildOrthogonal(start, end, path.points, sides);
|
|
3941
|
+
if (sides.fromSide === sides.toSide && pathCrossesObstacles(points, obstacles, edge.from, edge.to)) {
|
|
3942
|
+
const flipped = sides.fromSide === "left" ? "right" : "left";
|
|
3943
|
+
const altSides = {
|
|
3944
|
+
fromSide: flipped,
|
|
3945
|
+
toSide: flipped
|
|
3946
|
+
};
|
|
3947
|
+
const alt = rebuildOrthogonal(pointOnSide(fromLaid.bounds, flipped, start.y), pointOnSide(toLaid.bounds, flipped, end.y), path.points, altSides);
|
|
3948
|
+
if (!pathCrossesObstacles(alt, obstacles, edge.from, edge.to)) points = alt;
|
|
3949
|
+
}
|
|
3950
|
+
return {
|
|
3951
|
+
...path,
|
|
3952
|
+
points
|
|
3953
|
+
};
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
/** Choose left/right faces. Facing sides when horizontally separated; shared outer side when stacked. */
|
|
3957
|
+
function pickAttachmentSides(fromBounds, toBounds, original) {
|
|
3958
|
+
const fromRight = fromBounds.x + fromBounds.width;
|
|
3959
|
+
const toRight = toBounds.x + toBounds.width;
|
|
3960
|
+
const gapToRight = toBounds.x - fromRight;
|
|
3961
|
+
const gapToLeft = fromBounds.x - toRight;
|
|
3962
|
+
if (gapToRight > 0) return {
|
|
3963
|
+
fromSide: "right",
|
|
3964
|
+
toSide: "left"
|
|
3965
|
+
};
|
|
3966
|
+
if (gapToLeft > 0) return {
|
|
3967
|
+
fromSide: "left",
|
|
3968
|
+
toSide: "right"
|
|
3969
|
+
};
|
|
3970
|
+
const fromMid = fromBounds.x + fromBounds.width / 2;
|
|
3971
|
+
const hint = original[0];
|
|
3972
|
+
const side = (hint ? hint.x <= fromMid : true) ? "left" : "right";
|
|
3973
|
+
return {
|
|
3974
|
+
fromSide: side,
|
|
3975
|
+
toSide: side
|
|
3976
|
+
};
|
|
3977
|
+
}
|
|
3978
|
+
function pointOnSide(bounds, side, y) {
|
|
3979
|
+
return {
|
|
3980
|
+
x: side === "left" ? bounds.x : bounds.x + bounds.width,
|
|
3981
|
+
y
|
|
3982
|
+
};
|
|
3983
|
+
}
|
|
3984
|
+
function rebuildOrthogonal(start, end, original, sides) {
|
|
3985
|
+
if (Math.abs(start.y - end.y) < .5) return [start, end];
|
|
3986
|
+
if (Math.abs(start.x - end.x) < .5) return [start, end];
|
|
3987
|
+
if (sides.fromSide === sides.toSide) {
|
|
3988
|
+
const midX = sides.fromSide === "left" ? Math.min(start.x, end.x) - OUTER_CLEARANCE : Math.max(start.x, end.x) + OUTER_CLEARANCE;
|
|
3989
|
+
return [
|
|
3990
|
+
start,
|
|
3991
|
+
{
|
|
3992
|
+
x: midX,
|
|
3993
|
+
y: start.y
|
|
3994
|
+
},
|
|
3995
|
+
{
|
|
3996
|
+
x: midX,
|
|
3997
|
+
y: end.y
|
|
3998
|
+
},
|
|
3999
|
+
end
|
|
4000
|
+
];
|
|
4001
|
+
}
|
|
4002
|
+
const lo = Math.min(start.x, end.x);
|
|
4003
|
+
const hi = Math.max(start.x, end.x);
|
|
4004
|
+
let midX = (start.x + end.x) / 2;
|
|
4005
|
+
const interior = original.slice(1, -1);
|
|
4006
|
+
if (interior.length) {
|
|
4007
|
+
const xs = interior.map((p) => p.x).sort((a, b) => a - b);
|
|
4008
|
+
const candidate = xs[Math.floor(xs.length / 2)];
|
|
4009
|
+
if (candidate >= lo && candidate <= hi) midX = candidate;
|
|
4010
|
+
}
|
|
4011
|
+
return [
|
|
4012
|
+
start,
|
|
4013
|
+
{
|
|
4014
|
+
x: midX,
|
|
4015
|
+
y: start.y
|
|
4016
|
+
},
|
|
4017
|
+
{
|
|
4018
|
+
x: midX,
|
|
4019
|
+
y: end.y
|
|
4020
|
+
},
|
|
4021
|
+
end
|
|
4022
|
+
];
|
|
4023
|
+
}
|
|
4024
|
+
function pathCrossesObstacles(points, obstacles, fromId, toId) {
|
|
4025
|
+
const exclude = /* @__PURE__ */ new Set([fromId, toId]);
|
|
4026
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
4027
|
+
const a = points[i];
|
|
4028
|
+
const b = points[i + 1];
|
|
4029
|
+
for (const { nodeId, rect } of obstacles) {
|
|
4030
|
+
if (exclude.has(nodeId)) continue;
|
|
4031
|
+
if (segmentHitsRectInterior(a, b, rect)) return true;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
return false;
|
|
4035
|
+
}
|
|
4036
|
+
/** True when an orthogonal segment enters the open interior of a rect (not just grazing a face). */
|
|
4037
|
+
function segmentHitsRectInterior(from, to, rect) {
|
|
4038
|
+
const left = rect.x;
|
|
4039
|
+
const right = rect.x + rect.width;
|
|
4040
|
+
const top = rect.y;
|
|
4041
|
+
const bottom = rect.y + rect.height;
|
|
4042
|
+
const eps = .75;
|
|
4043
|
+
if (Math.abs(from.y - to.y) < eps) {
|
|
4044
|
+
const y = from.y;
|
|
4045
|
+
if (y <= top + eps || y >= bottom - eps) return false;
|
|
4046
|
+
const x0 = Math.min(from.x, to.x);
|
|
4047
|
+
const x1 = Math.max(from.x, to.x);
|
|
4048
|
+
return x0 < right - eps && x1 > left + eps;
|
|
4049
|
+
}
|
|
4050
|
+
if (Math.abs(from.x - to.x) < eps) {
|
|
4051
|
+
const x = from.x;
|
|
4052
|
+
if (x <= left + eps || x >= right - eps) return false;
|
|
4053
|
+
const y0 = Math.min(from.y, to.y);
|
|
4054
|
+
const y1 = Math.max(from.y, to.y);
|
|
4055
|
+
return y0 < bottom - eps && y1 > top + eps;
|
|
4056
|
+
}
|
|
4057
|
+
return false;
|
|
4058
|
+
}
|
|
4059
|
+
function erdRelationshipLabel(edge) {
|
|
4060
|
+
if (edge.fromColumn && edge.toColumn) return `${edge.toColumn} → ${edge.fromColumn}`;
|
|
4061
|
+
if (edge.toColumn) return edge.toColumn;
|
|
4062
|
+
if (edge.fromColumn) return edge.fromColumn;
|
|
4063
|
+
return edge.label;
|
|
4064
|
+
}
|
|
4065
|
+
//#endregion
|
|
4066
|
+
export { DEFAULT_FONT_FAMILY, ELK_LAYOUT_ALGORITHM, ELK_ROUTER_ALGORITHM, GROUP_ICON_GAP, GROUP_ICON_SIZE, SEQUENCE_LAYOUT_ALGORITHM, SEQUENCE_ROUTER_ALGORITHM, TABLE_ATTR_GAP, TABLE_BADGE_GAP, TABLE_BADGE_W, TABLE_HEADER_H, TABLE_KEY_COL, TABLE_PAD_X, TABLE_ROW_H, TABLE_RX, analyzeDiagramTopology, browserFontsReady, cardIconColumnWidth, columnAnchorY, columnNoteLabel, columnTypeLabel, computeGroupBounds, createCanvasMeasurer, defaultMeasurer, ensureBrowserFonts, erdRelationshipLabel, incomingCount, isChoiceBranch, isErdTableNode, isHorizontal, isSequenceGraph, isVertical, layoutAndRouteWithElk, layoutFromGraph, layoutSequence, measureGraph, measurerUsedApproximationFallback, outgoingCount, resetDefaultMeasurer, snapEdgeEndpointsToGeometry, snapErdEdgeEndpoints, tableKeyBadges };
|
|
4067
|
+
|
|
4068
|
+
//# sourceMappingURL=index.mjs.map
|