@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/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