@diagrammo/dgmo 0.8.20 → 0.8.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. package/src/wireframe/types.ts +110 -0
@@ -3,7 +3,7 @@
3
3
  // ============================================================
4
4
 
5
5
  import dagre from '@dagrejs/dagre';
6
- import type { ParsedBoxesAndLines, BLNode } from './types';
6
+ import type { ParsedBoxesAndLines, BLNode, BLGroup } from './types';
7
7
 
8
8
  /**
9
9
  * Clip a point at (cx, cy) to the border of a rectangle centered at (cx, cy)
@@ -38,8 +38,7 @@ const CONTAINER_PAD_X = 30;
38
38
  const CONTAINER_PAD_TOP = 40;
39
39
  const CONTAINER_PAD_BOTTOM = 24;
40
40
  const MAX_PARALLEL_EDGES = 5;
41
- const PARALLEL_SPACING = 12;
42
- const PARALLEL_EDGE_MARGIN = 10;
41
+ const PARALLEL_SPACING = 22;
43
42
 
44
43
  // ── Result types ───────────────────────────────────────────
45
44
 
@@ -88,13 +87,125 @@ export interface BLLayoutResult {
88
87
 
89
88
  // ── Node sizing ────────────────────────────────────────────
90
89
 
91
- function computeNodeSize(_node: BLNode): { width: number; height: number } {
92
- // Golden ratio (φ ≈ 1.618), uniform size
93
- const PHI = 1.618;
94
- const NODE_HEIGHT = 60;
95
- const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI); // 97
90
+ const PHI = 1.618;
91
+ const NODE_HEIGHT = 60;
92
+ const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI); // ≈ 97
93
+ const DESC_NODE_WIDTH = 140; // wider nodes when descriptions are shown
94
+ const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
95
+ const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px (matches infra META_LINE_HEIGHT)
96
+ const DESC_PADDING = 8;
97
+ const SEPARATOR_GAP = 4; // matches infra NODE_SEPARATOR_GAP
98
+ const MAX_DESC_LINES = 6;
99
+ const MAX_LABEL_LINES = 3;
100
+ const LABEL_LINE_HEIGHT = 1.3;
101
+ const LABEL_PAD = 12; // top + bottom padding around label area
102
+
103
+ /** Split on camelCase boundaries */
104
+ function splitCamelCase(word: string): string[] {
105
+ const parts: string[] = [];
106
+ let start = 0;
107
+ for (let i = 1; i < word.length; i++) {
108
+ const prev = word[i - 1];
109
+ const curr = word[i];
110
+ const next = i + 1 < word.length ? word[i + 1] : '';
111
+ const lowerToUpper =
112
+ prev >= 'a' && prev <= 'z' && curr >= 'A' && curr <= 'Z';
113
+ const upperRunEnd =
114
+ prev >= 'A' &&
115
+ prev <= 'Z' &&
116
+ curr >= 'A' &&
117
+ curr <= 'Z' &&
118
+ next >= 'a' &&
119
+ next <= 'z';
120
+ if (lowerToUpper || upperRunEnd) {
121
+ parts.push(word.slice(start, i));
122
+ start = i;
123
+ }
124
+ }
125
+ parts.push(word.slice(start));
126
+ return parts.length > 1 ? parts : [word];
127
+ }
128
+
129
+ /** Estimate how many lines a label needs (split on spaces/dashes/camelCase, font shrink 13→9) */
130
+ function estimateLabelLines(label: string, nodeWidth = NODE_WIDTH): number {
131
+ // Split on spaces and dashes, then camelCase
132
+ const rawParts = label.split(/[\s-]+/);
133
+ const words: string[] = [];
134
+ for (const part of rawParts) {
135
+ if (!part) continue;
136
+ words.push(...splitCamelCase(part));
137
+ }
138
+
139
+ for (let fontSize = 13; fontSize >= 9; fontSize--) {
140
+ const charWidth = fontSize * 0.6;
141
+ const maxChars = Math.floor((nodeWidth - 24) / charWidth);
142
+ if (maxChars < 2) continue;
143
+
144
+ let lines = 1;
145
+ let current = '';
146
+ for (const word of words) {
147
+ const test = current ? `${current} ${word}` : word;
148
+ if (test.length <= maxChars) {
149
+ current = test;
150
+ } else {
151
+ lines++;
152
+ current = word;
153
+ }
154
+ }
155
+ if (lines <= MAX_LABEL_LINES) return Math.min(lines, MAX_LABEL_LINES);
156
+ }
157
+ return MAX_LABEL_LINES;
158
+ }
159
+
160
+ function computeNodeSize(node: BLNode): { width: number; height: number } {
161
+ if (!node.description || node.description.length === 0) {
162
+ return { width: NODE_WIDTH, height: NODE_HEIGHT };
163
+ }
96
164
 
97
- return { width: NODE_WIDTH, height: NODE_HEIGHT };
165
+ const w = DESC_NODE_WIDTH;
166
+
167
+ // Estimate label height (up to 3 lines)
168
+ const labelLines = estimateLabelLines(node.label, w);
169
+ const labelHeight = labelLines * 13 * LABEL_LINE_HEIGHT + LABEL_PAD;
170
+
171
+ // Estimate wrapped line count using word-boundary wrapping (matches renderer)
172
+ const charsPerLine = Math.floor((w - 24) / (DESC_FONT_SIZE * 0.6));
173
+ let totalRenderedLines = 0;
174
+ for (const line of node.description) {
175
+ if (line.length <= charsPerLine) {
176
+ totalRenderedLines += 1;
177
+ } else {
178
+ const words = line.split(/\s+/);
179
+ let current = '';
180
+ let lineCount = 0;
181
+ for (const word of words) {
182
+ // Words wider than line get truncated with "…" in renderer (1 line)
183
+ const fitted =
184
+ word.length > charsPerLine ? word.slice(0, charsPerLine) : word;
185
+ const test = current ? `${current} ${fitted}` : fitted;
186
+ if (test.length <= charsPerLine) {
187
+ current = test;
188
+ } else {
189
+ if (current) lineCount++;
190
+ current = fitted;
191
+ }
192
+ }
193
+ if (current) lineCount++;
194
+ totalRenderedLines += lineCount;
195
+ }
196
+ }
197
+ totalRenderedLines = Math.min(totalRenderedLines, MAX_DESC_LINES);
198
+
199
+ const descriptionHeight =
200
+ totalRenderedLines * DESC_FONT_SIZE * DESC_LINE_HEIGHT;
201
+ const totalHeight =
202
+ labelHeight +
203
+ SEPARATOR_GAP +
204
+ DESC_PADDING +
205
+ descriptionHeight +
206
+ DESC_PADDING;
207
+
208
+ return { width: w, height: Math.max(NODE_HEIGHT, totalHeight) };
98
209
  }
99
210
 
100
211
  // ── Main layout ────────────────────────────────────────────
@@ -104,8 +215,10 @@ export function layoutBoxesAndLines(
104
215
  collapseInfo?: {
105
216
  collapsedChildCounts: Map<string, number>;
106
217
  originalGroups: import('./types').BLGroup[];
107
- }
218
+ },
219
+ layoutOptions?: { hideDescriptions?: boolean }
108
220
  ): BLLayoutResult {
221
+ const hideDescriptions = layoutOptions?.hideDescriptions ?? false;
109
222
  const g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
110
223
  g.setGraph({
111
224
  rankdir: parsed.direction,
@@ -116,23 +229,31 @@ export function layoutBoxesAndLines(
116
229
  });
117
230
  g.setDefaultEdgeLabel(() => ({}));
118
231
 
119
- // Determine which groups are collapsed
232
+ // Determine which groups are collapsed (but not hidden inside a collapsed parent)
120
233
  const collapsedGroupLabels = new Set<string>();
121
234
  if (collapseInfo) {
235
+ // Build set of all groups that are missing from parsed (collapsed or hidden)
236
+ const missingGroups = new Set<string>();
122
237
  for (const og of collapseInfo.originalGroups) {
123
238
  if (!parsed.groups.some((g) => g.label === og.label)) {
124
- collapsedGroupLabels.add(og.label);
239
+ missingGroups.add(og.label);
240
+ }
241
+ }
242
+ // Only show a collapsed group as a node if its parent is NOT also missing
243
+ // (i.e., it's a directly collapsed group, not one hidden inside a collapsed parent)
244
+ for (const label of missingGroups) {
245
+ const og = collapseInfo.originalGroups.find((g) => g.label === label);
246
+ const parentLabel = og?.parentGroup;
247
+ if (!parentLabel || !missingGroups.has(parentLabel)) {
248
+ collapsedGroupLabels.add(label);
125
249
  }
126
250
  }
127
251
  }
128
252
 
129
253
  // Add collapsed groups as regular nodes — same golden-ratio dimensions
130
- const PHI = 1.618;
131
- const COLLAPSED_H = 60;
132
- const COLLAPSED_W = Math.round(COLLAPSED_H * PHI);
133
254
  for (const label of collapsedGroupLabels) {
134
255
  const gid = `__group_${label}`;
135
- g.setNode(gid, { label, width: COLLAPSED_W, height: COLLAPSED_H });
256
+ g.setNode(gid, { label, width: NODE_WIDTH, height: NODE_HEIGHT });
136
257
  }
137
258
 
138
259
  // Add expanded group nodes as compound parents
@@ -147,9 +268,50 @@ export function layoutBoxesAndLines(
147
268
  });
148
269
  }
149
270
 
271
+ // Re-establish parent relationships for collapsed groups
272
+ // (must run AFTER expanded groups are added to the graph)
273
+ const originalGroupByLabel = new Map<string, BLGroup>();
274
+ if (collapseInfo) {
275
+ for (const og of collapseInfo.originalGroups) {
276
+ originalGroupByLabel.set(og.label, og);
277
+ }
278
+ }
279
+ for (const label of collapsedGroupLabels) {
280
+ const og = originalGroupByLabel.get(label);
281
+ if (og?.parentGroup && !collapsedGroupLabels.has(og.parentGroup)) {
282
+ const gid = `__group_${label}`;
283
+ const parentGid = `__group_${og.parentGroup}`;
284
+ if (g.hasNode(parentGid)) {
285
+ g.setParent(gid, parentGid);
286
+ }
287
+ }
288
+ }
289
+
290
+ // Compute node sizes — described nodes share uniform height (unless hidden)
291
+ const nodeSizes = new Map<string, { width: number; height: number }>();
292
+ let maxDescHeight = 0;
293
+ for (const node of parsed.nodes) {
294
+ const size = hideDescriptions
295
+ ? { width: NODE_WIDTH, height: NODE_HEIGHT }
296
+ : computeNodeSize(node);
297
+ nodeSizes.set(node.label, size);
298
+ if (!hideDescriptions && node.description && node.description.length > 0) {
299
+ maxDescHeight = Math.max(maxDescHeight, size.height);
300
+ }
301
+ }
302
+ // Apply uniform height to all described nodes
303
+ if (maxDescHeight > 0) {
304
+ for (const node of parsed.nodes) {
305
+ if (node.description && node.description.length > 0) {
306
+ const size = nodeSizes.get(node.label)!;
307
+ nodeSizes.set(node.label, { width: size.width, height: maxDescHeight });
308
+ }
309
+ }
310
+ }
311
+
150
312
  // Add nodes
151
313
  for (const node of parsed.nodes) {
152
- const size = computeNodeSize(node);
314
+ const size = nodeSizes.get(node.label)!;
153
315
  g.setNode(node.label, {
154
316
  label: node.label,
155
317
  width: size.width,
@@ -157,10 +319,26 @@ export function layoutBoxesAndLines(
157
319
  });
158
320
  }
159
321
 
322
+ // Set parent relationships for nested groups
323
+ for (const group of parsed.groups) {
324
+ if (group.parentGroup) {
325
+ const childGid = `__group_${group.label}`;
326
+ const parentGid = `__group_${group.parentGroup}`;
327
+ if (g.hasNode(childGid) && g.hasNode(parentGid)) {
328
+ g.setParent(childGid, parentGid);
329
+ }
330
+ }
331
+ }
332
+
333
+ // Build set of group labels for skip-check below
334
+ const groupLabelSet = new Set(parsed.groups.map((gr) => gr.label));
335
+
160
336
  // Set parent relationships for nodes in groups
161
337
  for (const group of parsed.groups) {
162
338
  const gid = `__group_${group.label}`;
163
339
  for (const child of group.children) {
340
+ // Skip children that are sub-groups — their parent is set above
341
+ if (groupLabelSet.has(child)) continue;
164
342
  if (g.hasNode(child)) {
165
343
  g.setParent(child, gid);
166
344
  }
@@ -174,8 +352,22 @@ export function layoutBoxesAndLines(
174
352
  expandedGroupIds.add(`__group_${group.label}`);
175
353
  }
176
354
 
355
+ // Map expanded group IDs to their first child node (for proxy edges)
356
+ const groupFirstChild = new Map<string, string>();
357
+ for (const group of parsed.groups) {
358
+ const gid = `__group_${group.label}`;
359
+ // Find first child that is a plain node (not a sub-group)
360
+ const firstChild = group.children.find(
361
+ (c) => !groupLabelSet.has(c) && g.hasNode(c)
362
+ );
363
+ if (firstChild) {
364
+ groupFirstChild.set(gid, firstChild);
365
+ }
366
+ }
367
+
177
368
  // Add edges — skip edges where either endpoint is an expanded compound parent
178
369
  const deferredEdgeIndices: number[] = [];
370
+ let proxyIdx = 0;
179
371
  for (let i = 0; i < parsed.edges.length; i++) {
180
372
  const edge = parsed.edges[i];
181
373
  const src = edge.source;
@@ -183,6 +375,21 @@ export function layoutBoxesAndLines(
183
375
  if (!g.hasNode(src) || !g.hasNode(tgt)) continue;
184
376
  if (expandedGroupIds.has(src) || expandedGroupIds.has(tgt)) {
185
377
  deferredEdgeIndices.push(i);
378
+ // Add invisible proxy edge between child nodes so dagre ranks the groups
379
+ const proxySrc = expandedGroupIds.has(src)
380
+ ? groupFirstChild.get(src)
381
+ : src;
382
+ const proxyTgt = expandedGroupIds.has(tgt)
383
+ ? groupFirstChild.get(tgt)
384
+ : tgt;
385
+ if (proxySrc && proxyTgt && proxySrc !== proxyTgt) {
386
+ g.setEdge(
387
+ proxySrc,
388
+ proxyTgt,
389
+ { label: '', minlen: 1 },
390
+ `proxy${proxyIdx++}`
391
+ );
392
+ }
186
393
  continue;
187
394
  }
188
395
  g.setEdge(src, tgt, { label: edge.label ?? '', minlen: 1 }, `e${i}`);
@@ -240,6 +447,88 @@ export function layoutBoxesAndLines(
240
447
  });
241
448
  }
242
449
 
450
+ // Center-align groups connected by group-to-group edges.
451
+ // Dagre can't rank expanded compound parents directly, and collapsed groups
452
+ // may also end up misaligned. Post-process to share a common center axis.
453
+ // Track per-group shifts so regular edge points can be adjusted too.
454
+ const groupAlignShifts = new Map<string, number>(); // gid → shift in alignment axis
455
+ {
456
+ // Find all group-to-group edges (both deferred and regular)
457
+ const groupEdges: { source: string; target: string }[] = [];
458
+ for (const edge of parsed.edges) {
459
+ if (
460
+ edge.source.startsWith('__group_') &&
461
+ edge.target.startsWith('__group_')
462
+ ) {
463
+ groupEdges.push(edge);
464
+ }
465
+ }
466
+
467
+ if (groupEdges.length > 0) {
468
+ // Build connected components via union-find
469
+ const groupParent = new Map<string, string>();
470
+ const find = (x: string): string => {
471
+ while (groupParent.has(x) && groupParent.get(x) !== x) {
472
+ groupParent.set(x, groupParent.get(groupParent.get(x)!)!);
473
+ x = groupParent.get(x)!;
474
+ }
475
+ return x;
476
+ };
477
+ const union = (a: string, b: string) => {
478
+ const ra = find(a),
479
+ rb = find(b);
480
+ if (ra !== rb) groupParent.set(ra, rb);
481
+ };
482
+
483
+ for (const edge of groupEdges) {
484
+ if (!groupParent.has(edge.source))
485
+ groupParent.set(edge.source, edge.source);
486
+ if (!groupParent.has(edge.target))
487
+ groupParent.set(edge.target, edge.target);
488
+ union(edge.source, edge.target);
489
+ }
490
+
491
+ // Group layout groups by connected component
492
+ const components = new Map<string, BLLayoutGroup[]>();
493
+ for (const lg of layoutGroups) {
494
+ const gid = `__group_${lg.label}`;
495
+ if (!groupParent.has(gid)) continue;
496
+ const root = find(gid);
497
+ if (!components.has(root)) components.set(root, []);
498
+ components.get(root)!.push(lg);
499
+ }
500
+
501
+ // For each component, align on the widest group's center
502
+ const axis = parsed.direction === 'TB' ? 'x' : 'y';
503
+ for (const groups of components.values()) {
504
+ if (groups.length < 2) continue;
505
+ const dim = axis === 'x' ? 'width' : 'height';
506
+ let widest = groups[0];
507
+ for (const g of groups) {
508
+ if (g[dim] > widest[dim]) widest = g;
509
+ }
510
+ const targetCenter = widest[axis];
511
+
512
+ for (const grp of groups) {
513
+ const dx = targetCenter - grp[axis];
514
+ if (dx === 0) continue;
515
+ grp[axis] += dx;
516
+ groupAlignShifts.set(`__group_${grp.label}`, dx);
517
+ // Shift child nodes in this group (expanded groups only)
518
+ const parsedGroup = parsed.groups.find(
519
+ (pg) => pg.label === grp.label
520
+ );
521
+ if (parsedGroup) {
522
+ for (const childLabel of parsedGroup.children) {
523
+ const childNode = layoutNodes.find((n) => n.label === childLabel);
524
+ if (childNode) childNode[axis] += dx;
525
+ }
526
+ }
527
+ }
528
+ }
529
+ }
530
+ }
531
+
243
532
  // Compute parallel edge offsets
244
533
  const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
245
534
  const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
@@ -263,10 +552,7 @@ export function layoutBoxesAndLines(
263
552
  edgeParallelCounts[idx] = 0;
264
553
  }
265
554
  if (capped.length < 2) continue;
266
- const effectiveSpacing = Math.min(
267
- PARALLEL_SPACING,
268
- (60 - PARALLEL_EDGE_MARGIN) / (capped.length - 1)
269
- );
555
+ const effectiveSpacing = PARALLEL_SPACING;
270
556
  for (let j = 0; j < capped.length; j++) {
271
557
  edgeYOffsets[capped[j]] =
272
558
  (j - (capped.length - 1) / 2) * effectiveSpacing;
@@ -284,32 +570,64 @@ export function layoutBoxesAndLines(
284
570
  let points: { x: number; y: number }[];
285
571
 
286
572
  if (deferredSet.has(i)) {
287
- // Deferred edge (compound parent endpoint) — compute points clipped to border
288
- const srcNode = g.node(edge.source);
289
- const tgtNode = g.node(edge.target);
290
- if (!srcNode || !tgtNode) continue;
291
- const srcPt = clipToRectBorder(
292
- srcNode.x,
293
- srcNode.y,
294
- srcNode.width,
295
- srcNode.height,
296
- tgtNode.x,
297
- tgtNode.y
573
+ // Deferred edge (compound parent endpoint) — use post-alignment layout
574
+ // positions and emit from center of the relevant border face
575
+ const srcLayout = layoutGroups.find(
576
+ (lg) => `__group_${lg.label}` === edge.source
298
577
  );
299
- const tgtPt = clipToRectBorder(
300
- tgtNode.x,
301
- tgtNode.y,
302
- tgtNode.width,
303
- tgtNode.height,
304
- srcNode.x,
305
- srcNode.y
578
+ const tgtLayout = layoutGroups.find(
579
+ (lg) => `__group_${lg.label}` === edge.target
306
580
  );
307
- const midX = (srcPt.x + tgtPt.x) / 2;
308
- const midY = (srcPt.y + tgtPt.y) / 2;
309
- points = [srcPt, { x: midX, y: midY }, tgtPt];
581
+ if (!srcLayout || !tgtLayout) {
582
+ // Fallback to dagre node positions for collapsed groups / mixed endpoints
583
+ const srcNode = g.node(edge.source);
584
+ const tgtNode = g.node(edge.target);
585
+ if (!srcNode || !tgtNode) continue;
586
+ const srcPt = clipToRectBorder(
587
+ srcNode.x,
588
+ srcNode.y,
589
+ srcNode.width,
590
+ srcNode.height,
591
+ tgtNode.x,
592
+ tgtNode.y
593
+ );
594
+ const tgtPt = clipToRectBorder(
595
+ tgtNode.x,
596
+ tgtNode.y,
597
+ tgtNode.width,
598
+ tgtNode.height,
599
+ srcNode.x,
600
+ srcNode.y
601
+ );
602
+ const midX = (srcPt.x + tgtPt.x) / 2;
603
+ const midY = (srcPt.y + tgtPt.y) / 2;
604
+ points = [srcPt, { x: midX, y: midY }, tgtPt];
605
+ } else if (parsed.direction === 'TB') {
606
+ // TB: straight vertical line from bottom-center to top-center
607
+ const cx = (srcLayout.x + tgtLayout.x) / 2;
608
+ const srcPt = { x: cx, y: srcLayout.y + srcLayout.height / 2 };
609
+ const tgtPt = { x: cx, y: tgtLayout.y - tgtLayout.height / 2 };
610
+ const midY = (srcPt.y + tgtPt.y) / 2;
611
+ points = [srcPt, { x: cx, y: midY }, tgtPt];
612
+ } else {
613
+ // LR: straight horizontal line from right-center to left-center
614
+ const cy = (srcLayout.y + tgtLayout.y) / 2;
615
+ const srcPt = { x: srcLayout.x + srcLayout.width / 2, y: cy };
616
+ const tgtPt = { x: tgtLayout.x - tgtLayout.width / 2, y: cy };
617
+ const midX = (srcPt.x + tgtPt.x) / 2;
618
+ points = [srcPt, { x: midX, y: cy }, tgtPt];
619
+ }
310
620
  } else {
311
621
  const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
312
622
  points = dagreEdge?.points ?? [];
623
+ // If endpoints were shifted by center-alignment, adjust edge points
624
+ const srcShift = groupAlignShifts.get(edge.source) ?? 0;
625
+ const tgtShift = groupAlignShifts.get(edge.target) ?? 0;
626
+ if (srcShift !== 0 || tgtShift !== 0) {
627
+ const avgShift = (srcShift + tgtShift) / 2;
628
+ const prop = parsed.direction === 'TB' ? 'x' : 'y';
629
+ points = points.map((p) => ({ ...p, [prop]: p[prop] + avgShift }));
630
+ }
313
631
  }
314
632
 
315
633
  // Compute label position at midpoint