@diagrammo/dgmo 0.8.21 → 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 (93) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +143 -93
  4. package/dist/editor.cjs +17 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +17 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +12 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +12 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +19997 -14886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +331 -8
  15. package/dist/index.d.ts +331 -8
  16. package/dist/index.js +19984 -14889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-sitemap.md +18 -1
  19. package/docs/guide/chart-tech-radar.md +219 -0
  20. package/docs/guide/registry.json +1 -0
  21. package/docs/language-reference.md +116 -6
  22. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  23. package/gallery/fixtures/c4-full.dgmo +2 -2
  24. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  25. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  26. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  27. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  28. package/gallery/fixtures/gantt-full.dgmo +2 -2
  29. package/gallery/fixtures/gantt.dgmo +2 -2
  30. package/gallery/fixtures/infra-full.dgmo +2 -2
  31. package/gallery/fixtures/infra.dgmo +1 -1
  32. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  33. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  34. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  35. package/gallery/fixtures/tech-radar.dgmo +36 -0
  36. package/gallery/fixtures/timeline.dgmo +1 -1
  37. package/package.json +1 -1
  38. package/src/boxes-and-lines/layout.ts +309 -33
  39. package/src/boxes-and-lines/parser.ts +86 -10
  40. package/src/boxes-and-lines/renderer.ts +250 -91
  41. package/src/boxes-and-lines/types.ts +1 -1
  42. package/src/c4/layout.ts +8 -8
  43. package/src/c4/parser.ts +35 -2
  44. package/src/c4/renderer.ts +19 -3
  45. package/src/c4/types.ts +1 -0
  46. package/src/chart.ts +14 -7
  47. package/src/completion.ts +227 -0
  48. package/src/cycle/layout.ts +732 -0
  49. package/src/cycle/parser.ts +352 -0
  50. package/src/cycle/renderer.ts +539 -0
  51. package/src/cycle/types.ts +77 -0
  52. package/src/d3.ts +87 -8
  53. package/src/dgmo-router.ts +9 -0
  54. package/src/echarts.ts +7 -4
  55. package/src/editor/dgmo.grammar +5 -1
  56. package/src/editor/dgmo.grammar.js +1 -1
  57. package/src/editor/keywords.ts +14 -0
  58. package/src/gantt/parser.ts +2 -8
  59. package/src/graph/flowchart-parser.ts +15 -21
  60. package/src/graph/state-parser.ts +5 -10
  61. package/src/index.ts +50 -0
  62. package/src/infra/layout.ts +218 -74
  63. package/src/infra/parser.ts +30 -6
  64. package/src/infra/renderer.ts +14 -8
  65. package/src/infra/types.ts +10 -3
  66. package/src/journey-map/layout.ts +386 -0
  67. package/src/journey-map/parser.ts +540 -0
  68. package/src/journey-map/renderer.ts +1456 -0
  69. package/src/journey-map/types.ts +47 -0
  70. package/src/kanban/parser.ts +3 -10
  71. package/src/kanban/renderer.ts +31 -15
  72. package/src/mindmap/parser.ts +12 -18
  73. package/src/mindmap/renderer.ts +14 -13
  74. package/src/mindmap/text-wrap.ts +22 -12
  75. package/src/mindmap/types.ts +2 -2
  76. package/src/org/parser.ts +2 -6
  77. package/src/sequence/renderer.ts +144 -38
  78. package/src/sharing.ts +1 -0
  79. package/src/sitemap/layout.ts +21 -6
  80. package/src/sitemap/parser.ts +26 -17
  81. package/src/sitemap/renderer.ts +34 -0
  82. package/src/sitemap/types.ts +1 -0
  83. package/src/tech-radar/index.ts +14 -0
  84. package/src/tech-radar/interactive.ts +1058 -0
  85. package/src/tech-radar/layout.ts +190 -0
  86. package/src/tech-radar/parser.ts +385 -0
  87. package/src/tech-radar/renderer.ts +1159 -0
  88. package/src/tech-radar/shared.ts +187 -0
  89. package/src/tech-radar/types.ts +81 -0
  90. package/src/utils/description-helpers.ts +33 -0
  91. package/src/utils/legend-layout.ts +3 -1
  92. package/src/utils/parsing.ts +46 -7
  93. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,77 @@
1
+ tech-radar Dense Radar Stress Test
2
+
3
+ rings
4
+ Adopt
5
+ Trial
6
+ Assess
7
+ Hold
8
+
9
+ Techniques | quadrant: top-right
10
+ CI/CD | ring: Adopt, trend: stable
11
+ IaC | ring: Adopt, trend: stable
12
+ Microservices | ring: Adopt, trend: stable
13
+ Event Sourcing | ring: Trial, trend: up
14
+ CQRS | ring: Trial, trend: up
15
+ Feature Flags | ring: Adopt, trend: new
16
+ Chaos Engineering | ring: Assess, trend: new
17
+ Trunk-Based Dev | ring: Trial, trend: up
18
+ Contract Testing | ring: Trial, trend: new
19
+ Design Tokens | ring: Assess, trend: new
20
+ Micro Frontends | ring: Assess, trend: up
21
+ Server Components | ring: Trial, trend: new
22
+ Edge Functions | ring: Trial, trend: up
23
+ Zero Trust | ring: Adopt, trend: stable
24
+ Observability | ring: Adopt, trend: stable
25
+ Platform Engineering | ring: Trial, trend: new
26
+
27
+ Tools | quadrant: top-left
28
+ GitHub Copilot | ring: Trial, trend: new
29
+ Vite | ring: Adopt, trend: up
30
+ Playwright | ring: Adopt, trend: stable
31
+ Turborepo | ring: Trial, trend: new
32
+ Docker | ring: Adopt, trend: stable
33
+ Grafana | ring: Adopt, trend: stable
34
+ Datadog | ring: Trial, trend: up
35
+ Webpack | ring: Hold, trend: down
36
+ Storybook | ring: Adopt, trend: stable
37
+ Figma | ring: Adopt, trend: stable
38
+ Linear | ring: Trial, trend: new
39
+ Pulumi | ring: Assess, trend: new
40
+ Tailscale | ring: Trial, trend: up
41
+ Renovate | ring: Adopt, trend: stable
42
+ Bruno | ring: Assess, trend: new
43
+ Cursor | ring: Trial, trend: new
44
+
45
+ Platforms | quadrant: bottom-left
46
+ Kubernetes | ring: Adopt, trend: stable
47
+ Cloudflare Workers | ring: Trial, trend: up
48
+ AWS Lambda | ring: Hold, trend: down
49
+ Vercel | ring: Trial, trend: up
50
+ Fly.io | ring: Assess, trend: new
51
+ Neon | ring: Trial, trend: new
52
+ PlanetScale | ring: Hold, trend: down
53
+ Supabase | ring: Assess, trend: up
54
+ Upstash | ring: Trial, trend: new
55
+ Railway | ring: Assess, trend: new
56
+ Deno Deploy | ring: Assess, trend: new
57
+ Google Cloud Run | ring: Adopt, trend: stable
58
+ Azure AKS | ring: Trial, trend: up
59
+ Hetzner | ring: Assess, trend: new
60
+ Tigris | ring: Assess, trend: new
61
+
62
+ Languages & Frameworks | quadrant: bottom-right
63
+ TypeScript | ring: Adopt, trend: stable
64
+ Rust | ring: Assess, trend: new
65
+ React | ring: Adopt, trend: stable
66
+ Go | ring: Adopt, trend: stable
67
+ Python | ring: Adopt, trend: stable
68
+ Svelte | ring: Assess, trend: up
69
+ Solid | ring: Assess, trend: new
70
+ Zig | ring: Hold, trend: stable
71
+ Kotlin | ring: Trial, trend: up
72
+ Swift | ring: Adopt, trend: stable
73
+ Astro | ring: Trial, trend: new
74
+ HTMX | ring: Assess, trend: new
75
+ Effect-TS | ring: Assess, trend: new
76
+ Bun | ring: Trial, trend: up
77
+ Deno | ring: Assess, trend: up
@@ -0,0 +1,36 @@
1
+ tech-radar Engineering Tech Radar Q2 2026
2
+
3
+ rings
4
+ Adopt
5
+ Trial
6
+ Assess
7
+ Hold
8
+
9
+ Techniques | quadrant: top-right
10
+ Continuous Deployment | ring: Adopt, trend: stable
11
+ Fully adopted across all services.
12
+ Infrastructure as Code | ring: Adopt, trend: stable
13
+ Micro Frontends | ring: Trial, trend: up
14
+ Exploring for the portal project.
15
+ Trunk-Based Development | ring: Assess, trend: new
16
+
17
+ Tools | quadrant: top-left
18
+ GitHub Copilot | ring: Trial, trend: new
19
+ Evaluating for developer productivity.
20
+ Vite | ring: Adopt, trend: up
21
+ Webpack | ring: Hold, trend: down
22
+ Migrating to Vite across all projects.
23
+ Playwright | ring: Adopt, trend: stable
24
+ Turborepo | ring: Trial, trend: new
25
+
26
+ Platforms | quadrant: bottom-left
27
+ Kubernetes | ring: Adopt, trend: stable
28
+ Cloudflare Workers | ring: Trial, trend: up
29
+ AWS Lambda | ring: Hold, trend: down
30
+ Consolidating onto Kubernetes.
31
+
32
+ Languages & Frameworks | quadrant: bottom-right
33
+ TypeScript | ring: Adopt, trend: stable
34
+ Rust | ring: Assess, trend: new
35
+ Evaluating for performance-critical services.
36
+ React | ring: Adopt, trend: stable
@@ -1,7 +1,7 @@
1
1
  timeline Product Roadmap 2024-2025
2
2
  sort tag:Team
3
3
 
4
- tag Team alias t
4
+ tag Team t
5
5
  Engineering(blue)
6
6
  Design(purple)
7
7
  Product(green)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -87,13 +87,125 @@ export interface BLLayoutResult {
87
87
 
88
88
  // ── Node sizing ────────────────────────────────────────────
89
89
 
90
- function computeNodeSize(_node: BLNode): { width: number; height: number } {
91
- // Golden ratio (φ ≈ 1.618), uniform size
92
- const PHI = 1.618;
93
- const NODE_HEIGHT = 60;
94
- 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
+ }
95
128
 
96
- return { width: NODE_WIDTH, height: NODE_HEIGHT };
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
+ }
164
+
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) };
97
209
  }
98
210
 
99
211
  // ── Main layout ────────────────────────────────────────────
@@ -103,8 +215,10 @@ export function layoutBoxesAndLines(
103
215
  collapseInfo?: {
104
216
  collapsedChildCounts: Map<string, number>;
105
217
  originalGroups: import('./types').BLGroup[];
106
- }
218
+ },
219
+ layoutOptions?: { hideDescriptions?: boolean }
107
220
  ): BLLayoutResult {
221
+ const hideDescriptions = layoutOptions?.hideDescriptions ?? false;
108
222
  const g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
109
223
  g.setGraph({
110
224
  rankdir: parsed.direction,
@@ -137,12 +251,9 @@ export function layoutBoxesAndLines(
137
251
  }
138
252
 
139
253
  // Add collapsed groups as regular nodes — same golden-ratio dimensions
140
- const PHI = 1.618;
141
- const COLLAPSED_H = 60;
142
- const COLLAPSED_W = Math.round(COLLAPSED_H * PHI);
143
254
  for (const label of collapsedGroupLabels) {
144
255
  const gid = `__group_${label}`;
145
- g.setNode(gid, { label, width: COLLAPSED_W, height: COLLAPSED_H });
256
+ g.setNode(gid, { label, width: NODE_WIDTH, height: NODE_HEIGHT });
146
257
  }
147
258
 
148
259
  // Add expanded group nodes as compound parents
@@ -176,9 +287,31 @@ export function layoutBoxesAndLines(
176
287
  }
177
288
  }
178
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
+
179
312
  // Add nodes
180
313
  for (const node of parsed.nodes) {
181
- const size = computeNodeSize(node);
314
+ const size = nodeSizes.get(node.label)!;
182
315
  g.setNode(node.label, {
183
316
  label: node.label,
184
317
  width: size.width,
@@ -219,8 +352,22 @@ export function layoutBoxesAndLines(
219
352
  expandedGroupIds.add(`__group_${group.label}`);
220
353
  }
221
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
+
222
368
  // Add edges — skip edges where either endpoint is an expanded compound parent
223
369
  const deferredEdgeIndices: number[] = [];
370
+ let proxyIdx = 0;
224
371
  for (let i = 0; i < parsed.edges.length; i++) {
225
372
  const edge = parsed.edges[i];
226
373
  const src = edge.source;
@@ -228,6 +375,21 @@ export function layoutBoxesAndLines(
228
375
  if (!g.hasNode(src) || !g.hasNode(tgt)) continue;
229
376
  if (expandedGroupIds.has(src) || expandedGroupIds.has(tgt)) {
230
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
+ }
231
393
  continue;
232
394
  }
233
395
  g.setEdge(src, tgt, { label: edge.label ?? '', minlen: 1 }, `e${i}`);
@@ -285,6 +447,88 @@ export function layoutBoxesAndLines(
285
447
  });
286
448
  }
287
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
+
288
532
  // Compute parallel edge offsets
289
533
  const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
290
534
  const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
@@ -326,32 +570,64 @@ export function layoutBoxesAndLines(
326
570
  let points: { x: number; y: number }[];
327
571
 
328
572
  if (deferredSet.has(i)) {
329
- // Deferred edge (compound parent endpoint) — compute points clipped to border
330
- const srcNode = g.node(edge.source);
331
- const tgtNode = g.node(edge.target);
332
- if (!srcNode || !tgtNode) continue;
333
- const srcPt = clipToRectBorder(
334
- srcNode.x,
335
- srcNode.y,
336
- srcNode.width,
337
- srcNode.height,
338
- tgtNode.x,
339
- 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
340
577
  );
341
- const tgtPt = clipToRectBorder(
342
- tgtNode.x,
343
- tgtNode.y,
344
- tgtNode.width,
345
- tgtNode.height,
346
- srcNode.x,
347
- srcNode.y
578
+ const tgtLayout = layoutGroups.find(
579
+ (lg) => `__group_${lg.label}` === edge.target
348
580
  );
349
- const midX = (srcPt.x + tgtPt.x) / 2;
350
- const midY = (srcPt.y + tgtPt.y) / 2;
351
- 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
+ }
352
620
  } else {
353
621
  const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
354
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
+ }
355
631
  }
356
632
 
357
633
  // Compute label position at midpoint