@diagrammo/dgmo 0.6.1 → 0.6.3

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 (52) hide show
  1. package/.claude/commands/dgmo.md +294 -0
  2. package/AGENTS.md +148 -0
  3. package/dist/cli.cjs +338 -163
  4. package/dist/index.cjs +1080 -319
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +28 -4
  7. package/dist/index.d.ts +28 -4
  8. package/dist/index.js +1078 -319
  9. package/dist/index.js.map +1 -1
  10. package/docs/ai-integration.md +33 -50
  11. package/package.json +8 -5
  12. package/src/c4/layout.ts +68 -10
  13. package/src/c4/parser.ts +0 -16
  14. package/src/c4/renderer.ts +1 -5
  15. package/src/class/layout.ts +0 -1
  16. package/src/class/parser.ts +28 -0
  17. package/src/class/renderer.ts +5 -26
  18. package/src/cli.ts +673 -2
  19. package/src/completion.ts +58 -0
  20. package/src/d3.ts +58 -106
  21. package/src/dgmo-router.ts +0 -57
  22. package/src/echarts.ts +96 -55
  23. package/src/er/classify.ts +206 -0
  24. package/src/er/layout.ts +259 -94
  25. package/src/er/parser.ts +30 -1
  26. package/src/er/renderer.ts +231 -18
  27. package/src/graph/flowchart-parser.ts +27 -4
  28. package/src/graph/flowchart-renderer.ts +1 -2
  29. package/src/graph/state-parser.ts +0 -1
  30. package/src/graph/state-renderer.ts +1 -3
  31. package/src/index.ts +10 -0
  32. package/src/infra/compute.ts +0 -7
  33. package/src/infra/layout.ts +60 -15
  34. package/src/infra/parser.ts +46 -4
  35. package/src/infra/renderer.ts +376 -47
  36. package/src/initiative-status/layout.ts +46 -30
  37. package/src/initiative-status/renderer.ts +5 -25
  38. package/src/kanban/parser.ts +0 -2
  39. package/src/org/layout.ts +0 -4
  40. package/src/org/renderer.ts +7 -28
  41. package/src/sequence/parser.ts +14 -11
  42. package/src/sequence/renderer.ts +0 -2
  43. package/src/sequence/tag-resolution.ts +0 -1
  44. package/src/sitemap/layout.ts +1 -14
  45. package/src/sitemap/parser.ts +1 -2
  46. package/src/sitemap/renderer.ts +0 -3
  47. package/src/utils/arrows.ts +7 -7
  48. package/src/utils/export-container.ts +40 -0
  49. package/.claude/skills/dgmo-chart/SKILL.md +0 -141
  50. package/.claude/skills/dgmo-flowchart/SKILL.md +0 -61
  51. package/.claude/skills/dgmo-generate/SKILL.md +0 -59
  52. package/.claude/skills/dgmo-sequence/SKILL.md +0 -104
@@ -13,12 +13,20 @@ import {
13
13
  LEGEND_HEIGHT,
14
14
  LEGEND_PILL_PAD,
15
15
  LEGEND_PILL_FONT_SIZE,
16
+ LEGEND_PILL_FONT_W,
17
+ LEGEND_CAPSULE_PAD,
18
+ LEGEND_DOT_R,
19
+ LEGEND_ENTRY_FONT_SIZE,
20
+ LEGEND_ENTRY_DOT_GAP,
21
+ LEGEND_ENTRY_TRAIL,
16
22
  LEGEND_GROUP_GAP,
17
23
  } from '../utils/legend-constants';
18
24
  import type { ParsedERDiagram, ERConstraint } from './types';
19
- import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
25
+ import type { ERLayoutResult } from './layout';
20
26
  import { parseERDiagram } from './parser';
21
27
  import { layoutERDiagram } from './layout';
28
+ import { classifyEREntities, ROLE_COLORS, ROLE_LABELS, ROLE_ORDER } from './classify';
29
+ import type { EntityRole } from './classify';
22
30
 
23
31
  // ============================================================
24
32
  // Constants
@@ -208,32 +216,62 @@ export function renderERDiagram(
208
216
  isDark: boolean,
209
217
  onClickItem?: (lineNumber: number) => void,
210
218
  exportDims?: { width?: number; height?: number },
211
- activeTagGroup?: string | null
219
+ activeTagGroup?: string | null,
220
+ /** When false, semantic role colors are suppressed and entities use a neutral color. */
221
+ semanticColorsActive?: boolean
212
222
  ): void {
213
223
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
214
224
 
215
- const width = exportDims?.width ?? container.clientWidth;
216
- const height = exportDims?.height ?? container.clientHeight;
217
- if (width <= 0 || height <= 0) return;
225
+ const useSemanticColors =
226
+ parsed.tagGroups.length === 0 && layout.nodes.every((n) => !n.color);
227
+ const legendReserveH = useSemanticColors ? LEGEND_HEIGHT + DIAGRAM_PADDING : 0;
218
228
 
219
229
  const titleHeight = parsed.title ? 40 : 0;
220
230
  const diagramW = layout.width;
221
231
  const diagramH = layout.height;
222
- const availH = height - titleHeight;
223
- const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
224
- const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
225
- const scale = Math.min(MAX_SCALE, scaleX, scaleY);
226
232
 
227
- const scaledW = diagramW * scale;
228
- const scaledH = diagramH * scale;
229
- const offsetX = (width - scaledW) / 2;
230
- const offsetY = titleHeight + DIAGRAM_PADDING;
233
+ // Natural dimensions derived purely from the layout — no dependency on container
234
+ // size at render time, which eliminates the stagger caused by reading clientWidth/
235
+ // clientHeight before the container has stabilized.
236
+ const naturalW = diagramW + DIAGRAM_PADDING * 2;
237
+ const naturalH = diagramH + titleHeight + legendReserveH + DIAGRAM_PADDING * 2;
238
+
239
+ // For export: scale the natural layout to fit the requested pixel dimensions.
240
+ // For live preview: render at natural scale (scale=1) and let the SVG viewBox
241
+ // handle fitting to the container via CSS.
242
+ let viewW: number;
243
+ let viewH: number;
244
+ let scale: number;
245
+ let offsetX: number;
246
+ let offsetY: number;
247
+
248
+ if (exportDims) {
249
+ viewW = exportDims.width ?? naturalW;
250
+ viewH = exportDims.height ?? naturalH;
251
+ const availH = viewH - titleHeight - legendReserveH;
252
+ const scaleX = (viewW - DIAGRAM_PADDING * 2) / diagramW;
253
+ const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
254
+ scale = Math.min(MAX_SCALE, scaleX, scaleY);
255
+ const scaledW = diagramW * scale;
256
+ offsetX = (viewW - scaledW) / 2;
257
+ offsetY = titleHeight + DIAGRAM_PADDING;
258
+ } else {
259
+ viewW = naturalW;
260
+ viewH = naturalH;
261
+ scale = 1;
262
+ offsetX = DIAGRAM_PADDING;
263
+ offsetY = titleHeight + DIAGRAM_PADDING;
264
+ }
265
+
266
+ if (viewW <= 0 || viewH <= 0) return;
231
267
 
232
268
  const svg = d3Selection
233
269
  .select(container)
234
270
  .append('svg')
235
- .attr('width', width)
236
- .attr('height', height)
271
+ .attr('width', exportDims ? viewW : '100%')
272
+ .attr('height', exportDims ? viewH : '100%')
273
+ .attr('viewBox', `0 0 ${viewW} ${viewH}`)
274
+ .attr('preserveAspectRatio', 'xMidYMid meet')
237
275
  .style('font-family', FONT_FAMILY);
238
276
 
239
277
  // ── Title ──
@@ -241,7 +279,7 @@ export function renderERDiagram(
241
279
  const titleEl = svg
242
280
  .append('text')
243
281
  .attr('class', 'chart-title')
244
- .attr('x', width / 2)
282
+ .attr('x', viewW / 2)
245
283
  .attr('y', 30)
246
284
  .attr('text-anchor', 'middle')
247
285
  .attr('fill', palette.text)
@@ -269,6 +307,15 @@ export function renderERDiagram(
269
307
  // ── Auto-assign colors ──
270
308
  const seriesColors = getSeriesColors(palette);
271
309
 
310
+ // ── Semantic coloring gate ──
311
+ // Classify entities whenever conditions allow; suppress colors when user collapses the legend.
312
+ // (useSemanticColors was computed above for legend reserve height)
313
+ const semanticRoles: Map<string, EntityRole> | null = useSemanticColors
314
+ ? classifyEREntities(parsed.tables, parsed.relationships)
315
+ : null;
316
+ // semanticColorsActive defaults to true; false = legend collapsed, neutral color applied
317
+ const semanticActive = semanticRoles !== null && (semanticColorsActive ?? true);
318
+
272
319
  // ── Edges (behind nodes) ──
273
320
  const useLabels = parsed.options.notation === 'labels';
274
321
 
@@ -347,7 +394,12 @@ export function renderERDiagram(
347
394
  for (let ni = 0; ni < layout.nodes.length; ni++) {
348
395
  const node = layout.nodes[ni];
349
396
  const tagColor = resolveTagColor(node.metadata, parsed.tagGroups, activeTagGroup ?? null);
350
- const nodeColor = node.color ?? tagColor ?? seriesColors[ni % seriesColors.length];
397
+ const semanticColor = semanticActive
398
+ ? palette.colors[ROLE_COLORS[semanticRoles!.get(node.id) ?? 'unclassified']]
399
+ : semanticRoles
400
+ ? palette.primary // neutral color when legend is collapsed
401
+ : undefined;
402
+ const nodeColor = node.color ?? tagColor ?? semanticColor ?? seriesColors[ni % seriesColors.length];
351
403
 
352
404
  const nodeG = contentG
353
405
  .append('g')
@@ -365,6 +417,12 @@ export function renderERDiagram(
365
417
  }
366
418
  }
367
419
 
420
+ // Set data-er-role for semantic coloring (CSS targeting + test assertions)
421
+ if (semanticRoles) {
422
+ const role = semanticRoles.get(node.id);
423
+ if (role) nodeG.attr('data-er-role', role);
424
+ }
425
+
368
426
  if (onClickItem) {
369
427
  nodeG.style('cursor', 'pointer').on('click', () => {
370
428
  onClickItem(node.lineNumber);
@@ -463,7 +521,7 @@ export function renderERDiagram(
463
521
  }
464
522
 
465
523
  let legendX = DIAGRAM_PADDING;
466
- let legendY = height - DIAGRAM_PADDING;
524
+ let legendY = viewH - DIAGRAM_PADDING;
467
525
 
468
526
  for (const group of parsed.tagGroups) {
469
527
  const groupG = legendG.append('g')
@@ -525,6 +583,161 @@ export function renderERDiagram(
525
583
  legendX += LEGEND_GROUP_GAP;
526
584
  }
527
585
  }
586
+
587
+ // ── Semantic Legend ──
588
+ // Rendered when semantic role detection is enabled (no tag groups, no explicit colors).
589
+ // Follows the sequence-legend pattern: one clickable "Role" group pill that expands
590
+ // to show colored-dot entries. Clicking toggles semanticColorsActive on/off.
591
+ if (semanticRoles) {
592
+ const presentRoles = ROLE_ORDER.filter((role) => {
593
+ for (const r of semanticRoles.values()) {
594
+ if (r === role) return true;
595
+ }
596
+ return false;
597
+ });
598
+
599
+ if (presentRoles.length > 0) {
600
+ // Measure actual text widths for consistent spacing regardless of character mix.
601
+ // Falls back to a character-count estimate in jsdom/test environments.
602
+ const measureLabelW = (text: string, fontSize: number): number => {
603
+ const dummy = svg.append('text')
604
+ .attr('font-size', fontSize)
605
+ .attr('font-family', FONT_FAMILY)
606
+ .attr('visibility', 'hidden')
607
+ .text(text);
608
+ const measured = (dummy.node() as SVGTextElement | null)?.getComputedTextLength?.() ?? 0;
609
+ dummy.remove();
610
+ return measured > 0 ? measured : text.length * fontSize * 0.6;
611
+ };
612
+
613
+ const labelWidths = new Map<EntityRole, number>();
614
+ for (const role of presentRoles) {
615
+ labelWidths.set(role, measureLabelW(ROLE_LABELS[role], LEGEND_ENTRY_FONT_SIZE));
616
+ }
617
+
618
+ const groupBg = isDark
619
+ ? mix(palette.surface, palette.bg, 50)
620
+ : mix(palette.surface, palette.bg, 30);
621
+
622
+ const groupName = 'Role';
623
+ const pillWidth = groupName.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
624
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
625
+
626
+ let totalWidth: number;
627
+ let entriesWidth = 0;
628
+ if (semanticActive) {
629
+ for (const role of presentRoles) {
630
+ entriesWidth +=
631
+ LEGEND_DOT_R * 2 +
632
+ LEGEND_ENTRY_DOT_GAP +
633
+ labelWidths.get(role)! +
634
+ LEGEND_ENTRY_TRAIL;
635
+ }
636
+ totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + LEGEND_ENTRY_TRAIL + entriesWidth;
637
+ } else {
638
+ totalWidth = pillWidth;
639
+ }
640
+
641
+ const legendX = (viewW - totalWidth) / 2;
642
+ const legendY = viewH - DIAGRAM_PADDING - LEGEND_HEIGHT;
643
+
644
+ const semanticLegendG = svg
645
+ .append('g')
646
+ .attr('class', 'er-semantic-legend')
647
+ .attr('data-legend-group', 'role')
648
+ .attr('transform', `translate(${legendX}, ${legendY})`)
649
+ .style('cursor', 'pointer');
650
+
651
+ if (semanticActive) {
652
+ // ── Expanded: outer capsule + inner pill + dot entries ──
653
+ semanticLegendG
654
+ .append('rect')
655
+ .attr('width', totalWidth)
656
+ .attr('height', LEGEND_HEIGHT)
657
+ .attr('rx', LEGEND_HEIGHT / 2)
658
+ .attr('fill', groupBg);
659
+
660
+ semanticLegendG
661
+ .append('rect')
662
+ .attr('x', LEGEND_CAPSULE_PAD)
663
+ .attr('y', LEGEND_CAPSULE_PAD)
664
+ .attr('width', pillWidth)
665
+ .attr('height', pillH)
666
+ .attr('rx', pillH / 2)
667
+ .attr('fill', palette.bg);
668
+
669
+ semanticLegendG
670
+ .append('rect')
671
+ .attr('x', LEGEND_CAPSULE_PAD)
672
+ .attr('y', LEGEND_CAPSULE_PAD)
673
+ .attr('width', pillWidth)
674
+ .attr('height', pillH)
675
+ .attr('rx', pillH / 2)
676
+ .attr('fill', 'none')
677
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
678
+ .attr('stroke-width', 0.75);
679
+
680
+ semanticLegendG
681
+ .append('text')
682
+ .attr('x', LEGEND_CAPSULE_PAD + pillWidth / 2)
683
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
684
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
685
+ .attr('font-weight', '500')
686
+ .attr('fill', palette.text)
687
+ .attr('text-anchor', 'middle')
688
+ .attr('font-family', FONT_FAMILY)
689
+ .text(groupName);
690
+
691
+ let entryX = LEGEND_CAPSULE_PAD + pillWidth + LEGEND_ENTRY_TRAIL;
692
+ for (const role of presentRoles) {
693
+ const label = ROLE_LABELS[role];
694
+ const roleColor = palette.colors[ROLE_COLORS[role]];
695
+
696
+ const entryG = semanticLegendG
697
+ .append('g')
698
+ .attr('data-legend-entry', role);
699
+
700
+ entryG
701
+ .append('circle')
702
+ .attr('cx', entryX + LEGEND_DOT_R)
703
+ .attr('cy', LEGEND_HEIGHT / 2)
704
+ .attr('r', LEGEND_DOT_R)
705
+ .attr('fill', roleColor);
706
+
707
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
708
+ entryG
709
+ .append('text')
710
+ .attr('x', textX)
711
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
712
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
713
+ .attr('fill', palette.textMuted)
714
+ .attr('font-family', FONT_FAMILY)
715
+ .text(label);
716
+
717
+ entryX = textX + labelWidths.get(role)! + LEGEND_ENTRY_TRAIL;
718
+ }
719
+ } else {
720
+ // ── Collapsed: single muted pill, no entries ──
721
+ semanticLegendG
722
+ .append('rect')
723
+ .attr('width', pillWidth)
724
+ .attr('height', LEGEND_HEIGHT)
725
+ .attr('rx', LEGEND_HEIGHT / 2)
726
+ .attr('fill', groupBg);
727
+
728
+ semanticLegendG
729
+ .append('text')
730
+ .attr('x', pillWidth / 2)
731
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
732
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
733
+ .attr('font-weight', '500')
734
+ .attr('fill', palette.textMuted)
735
+ .attr('text-anchor', 'middle')
736
+ .attr('font-family', FONT_FAMILY)
737
+ .text(groupName);
738
+ }
739
+ }
740
+ }
528
741
  }
529
742
 
530
743
  // ============================================================
@@ -90,10 +90,6 @@ function parseNodeRef(
90
90
  */
91
91
  function splitArrows(line: string): string[] {
92
92
  const segments: string[] = [];
93
- // Match: optional `-label(color)->` or just `->`
94
- // We scan left to right looking for `->` and work backwards to find the `-` start.
95
- const arrowRe = /(?:^|\s)-([^>\s(][^(>]*?)?\s*(?:\(([^)]+)\))?\s*->|(?:^|\s)->/g;
96
-
97
93
  let lastIndex = 0;
98
94
  // Simpler approach: find all `->` positions, then determine if there's a label prefix
99
95
  const arrowPositions: { start: number; end: number; label?: string; color?: string }[] = [];
@@ -482,3 +478,30 @@ export function looksLikeFlowchart(content: string): boolean {
482
478
 
483
479
  return shapeNearArrow;
484
480
  }
481
+
482
+ // ============================================================
483
+ // Symbol extraction (for completion API)
484
+ // ============================================================
485
+
486
+ import type { DiagramSymbols } from '../completion';
487
+
488
+ // Node ID: identifier at line start followed by a shape delimiter or space (arrow line)
489
+ const NODE_ID_RE = /^([a-zA-Z_][\w-]*)[\s([</{]/;
490
+
491
+ /**
492
+ * Extract node IDs (entities) from flowchart document text.
493
+ * Used by the dgmo completion API for ghost hints and popup completions.
494
+ */
495
+ export function extractSymbols(docText: string): DiagramSymbols {
496
+ const entities: string[] = [];
497
+ let inMetadata = true;
498
+ for (const rawLine of docText.split('\n')) {
499
+ const line = rawLine.trim();
500
+ if (inMetadata && /^[a-z-]+\s*:/i.test(line)) continue;
501
+ inMetadata = false;
502
+ if (line.length === 0 || /^\s/.test(rawLine)) continue;
503
+ const m = NODE_ID_RE.exec(line);
504
+ if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
505
+ }
506
+ return { kind: 'flowchart', entities, keywords: [] };
507
+ }
@@ -8,7 +8,7 @@ import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import type { ParsedGraph, GraphShape } from './types';
11
- import type { LayoutResult, LayoutNode, LayoutEdge } from './layout';
11
+ import type { LayoutResult, LayoutNode } from './layout';
12
12
  import { parseFlowchart } from './flowchart-parser';
13
13
  import { layoutGraph } from './layout';
14
14
 
@@ -246,7 +246,6 @@ export function renderFlowchart(
246
246
 
247
247
  // Center the diagram in the area below the title
248
248
  const scaledW = diagramW * scale;
249
- const scaledH = diagramH * scale;
250
249
  const offsetX = (width - scaledW) / 2;
251
250
  const offsetY = titleHeight + DIAGRAM_PADDING;
252
251
 
@@ -5,7 +5,6 @@ import { measureIndent, extractColor } from '../utils/parsing';
5
5
  import type {
6
6
  ParsedGraph,
7
7
  GraphNode,
8
- GraphEdge,
9
8
  GraphGroup,
10
9
  GraphDirection,
11
10
  } from './types';
@@ -8,7 +8,7 @@ import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import type { ParsedGraph } from './types';
11
- import type { LayoutResult, LayoutNode, LayoutEdge } from './layout';
11
+ import type { LayoutResult, LayoutNode } from './layout';
12
12
  import { parseState } from './state-parser';
13
13
  import { layoutGraph } from './layout';
14
14
 
@@ -76,8 +76,6 @@ function selfLoopPath(node: LayoutNode): string {
76
76
  // Main renderer
77
77
  // ============================================================
78
78
 
79
- type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
80
-
81
79
  export function renderState(
82
80
  container: HTMLDivElement,
83
81
  graph: ParsedGraph,
package/src/index.ts CHANGED
@@ -361,6 +361,16 @@ export type {
361
361
  DecodedDiagramUrl,
362
362
  } from './sharing';
363
363
 
364
+ // ============================================================
365
+ // Completion (symbol extraction API)
366
+ // ============================================================
367
+
368
+ export {
369
+ registerExtractor,
370
+ extractDiagramSymbols,
371
+ } from './completion';
372
+ export type { DiagramSymbols, ExtractFn } from './completion';
373
+
364
374
  // ============================================================
365
375
  // Branding
366
376
  // ============================================================
@@ -22,8 +22,6 @@ import type {
22
22
  InfraAvailabilityPercentiles,
23
23
  InfraProperty,
24
24
  } from './types';
25
- import { INFRA_BEHAVIOR_KEYS } from './types';
26
-
27
25
  // ============================================================
28
26
  // Helpers
29
27
  // ============================================================
@@ -71,11 +69,6 @@ function serverlessCapacity(node: InfraNode): number {
71
69
  return concurrency / (durationMs / 1000);
72
70
  }
73
71
 
74
- /** Backward-compatible helper used by overload detection. */
75
- function getInstances(node: InfraNode): number {
76
- return getInstanceRange(node).min;
77
- }
78
-
79
72
  /** Compute dynamic instance count based on load and max-rps. */
80
73
  function computeDynamicInstances(node: InfraNode, computedRps: number): number {
81
74
  const { min, max } = getInstanceRange(node);
@@ -9,8 +9,6 @@ import dagre from '@dagrejs/dagre';
9
9
  import type {
10
10
  ComputedInfraModel,
11
11
  ComputedInfraNode,
12
- ComputedInfraEdge,
13
- InfraGroup,
14
12
  } from './types';
15
13
 
16
14
  // ============================================================
@@ -73,6 +71,7 @@ export interface InfraLayoutResult {
73
71
  groups: InfraLayoutGroup[];
74
72
  /** Diagram-level options (e.g., default-latency-ms, default-uptime). */
75
73
  options: Record<string, string>;
74
+ direction: 'LR' | 'TB';
76
75
  width: number;
77
76
  height: number;
78
77
  }
@@ -110,12 +109,12 @@ const DISPLAY_KEYS = new Set([
110
109
 
111
110
  /** Display names for width estimation. */
112
111
  const DISPLAY_NAMES: Record<string, string> = {
113
- 'cache-hit': 'cache hit', 'firewall-block': 'fw block',
112
+ 'cache-hit': 'cache hit', 'firewall-block': 'firewall block',
114
113
  'ratelimit-rps': 'rate limit RPS', 'latency-ms': 'latency', 'uptime': 'uptime',
115
114
  'instances': 'instances', 'max-rps': 'max RPS',
116
- 'cb-error-threshold': 'CB error', 'cb-latency-threshold-ms': 'CB latency',
115
+ 'cb-error-threshold': 'CB error threshold', 'cb-latency-threshold-ms': 'CB latency threshold',
117
116
  'concurrency': 'concurrency', 'duration-ms': 'duration', 'cold-start-ms': 'cold start',
118
- 'buffer': 'buffer', 'drain-rate': 'drain', 'retention-hours': 'retention', 'partitions': 'partitions',
117
+ 'buffer': 'buffer', 'drain-rate': 'drain rate', 'retention-hours': 'retention', 'partitions': 'partitions',
119
118
  };
120
119
 
121
120
  function countDisplayProps(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
@@ -356,18 +355,20 @@ function formatUptime(fraction: number): string {
356
355
  // Group separation pass
357
356
  // ============================================================
358
357
 
359
- const GROUP_GAP = 24; // min clear gap between group boxes — matches GROUP_HEADER_HEIGHT
358
+ const GROUP_GAP = GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT; // min clear gap between group boxes
360
359
 
361
360
  export function separateGroups(
362
361
  groups: InfraLayoutGroup[],
363
362
  nodes: InfraLayoutNode[],
364
363
  isLR: boolean,
365
364
  maxIterations = 20,
366
- ): void {
365
+ ): Map<string, { dx: number; dy: number }> {
367
366
  // Symmetric 2D rectangle intersection — no sorting needed, handles all
368
367
  // relative positions correctly, stable after mid-pass shifts.
369
368
  // Endpoint edge routing is not affected: renderer.ts recomputes border
370
369
  // connection points from node x/y at render time via nodeBorderPoint().
370
+ const groupDeltas = new Map<string, { dx: number; dy: number }>();
371
+ let converged = false;
371
372
  for (let iter = 0; iter < maxIterations; iter++) {
372
373
  let anyOverlap = false;
373
374
  for (let i = 0; i < groups.length; i++) {
@@ -398,6 +399,11 @@ export function separateGroups(
398
399
  if (isLR) groupToShift.y += shift;
399
400
  else groupToShift.x += shift;
400
401
 
402
+ // Accumulate the total delta for this group (used by fixEdgeWaypoints)
403
+ const prev = groupDeltas.get(groupToShift.id) ?? { dx: 0, dy: 0 };
404
+ if (isLR) groupDeltas.set(groupToShift.id, { dx: prev.dx, dy: prev.dy + shift });
405
+ else groupDeltas.set(groupToShift.id, { dx: prev.dx + shift, dy: prev.dy });
406
+
401
407
  for (const node of nodes) {
402
408
  if (node.groupId === groupToShift.id) {
403
409
  if (isLR) node.y += shift;
@@ -406,7 +412,44 @@ export function separateGroups(
406
412
  }
407
413
  }
408
414
  }
409
- if (!anyOverlap) break;
415
+ if (!anyOverlap) { converged = true; break; }
416
+ }
417
+ if (!converged && maxIterations > 0) {
418
+ console.warn(`separateGroups: hit maxIterations (${maxIterations}) without fully resolving all group overlaps`);
419
+ }
420
+ return groupDeltas;
421
+ }
422
+
423
+ export function fixEdgeWaypoints(
424
+ edges: InfraLayoutEdge[],
425
+ nodes: InfraLayoutNode[],
426
+ groupDeltas: Map<string, { dx: number; dy: number }>,
427
+ ): void {
428
+ if (groupDeltas.size === 0) return;
429
+ const nodeToGroup = new Map<string, string | null>();
430
+ for (const node of nodes) nodeToGroup.set(node.id, node.groupId);
431
+
432
+ for (const edge of edges) {
433
+ const srcGroup = nodeToGroup.get(edge.sourceId) ?? null;
434
+ // Group-targeting edges (targetId is a group ID, not a node) return undefined from the map → null →
435
+ // treated as "ungrouped target", which is the correct approximation.
436
+ const tgtGroup = nodeToGroup.get(edge.targetId) ?? null;
437
+ const srcDelta = srcGroup ? groupDeltas.get(srcGroup) : undefined;
438
+ const tgtDelta = tgtGroup ? groupDeltas.get(tgtGroup) : undefined;
439
+
440
+ if (!srcDelta && !tgtDelta) continue; // neither side shifted
441
+
442
+ if (srcDelta && tgtDelta && srcGroup !== tgtGroup) {
443
+ // both sides in different shifted groups — discard, renderer draws a straight line
444
+ edge.points = [];
445
+ continue;
446
+ }
447
+
448
+ const delta = srcDelta ?? tgtDelta!;
449
+ for (const pt of edge.points) {
450
+ pt.x += delta.dx;
451
+ pt.y += delta.dy;
452
+ }
410
453
  }
411
454
  }
412
455
 
@@ -416,15 +459,16 @@ export function separateGroups(
416
459
 
417
460
  export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<string> | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
418
461
  if (computed.nodes.length === 0) {
419
- return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
462
+ return { nodes: [], edges: [], groups: [], options: {}, direction: computed.direction, width: 0, height: 0 };
420
463
  }
421
464
 
465
+ const isLR = computed.direction !== 'TB';
422
466
  const g = new dagre.graphlib.Graph();
423
467
  g.setGraph({
424
468
  rankdir: computed.direction === 'TB' ? 'TB' : 'LR',
425
- nodesep: 50,
426
- ranksep: 100,
427
- edgesep: 20,
469
+ nodesep: isLR ? 70 : 60,
470
+ ranksep: isLR ? 150 : 120,
471
+ edgesep: 30,
428
472
  });
429
473
  g.setDefaultEdgeLabel(() => ({}));
430
474
 
@@ -436,7 +480,6 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
436
480
 
437
481
  // Extra space dagre must reserve for the group bounding box
438
482
  const GROUP_INFLATE = GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT;
439
- const isLR = computed.direction !== 'TB';
440
483
 
441
484
  // Add nodes — inflate grouped nodes so dagre accounts for group boxes
442
485
  const widthMap = new Map<string, number>();
@@ -591,8 +634,9 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
591
634
  };
592
635
  });
593
636
 
594
- // Separate overlapping groups (post-layout pass)
595
- separateGroups(layoutGroups, layoutNodes, isLR);
637
+ // Separate overlapping groups (post-layout pass) and fix stale edge waypoints
638
+ const groupDeltas = separateGroups(layoutGroups, layoutNodes, isLR);
639
+ fixEdgeWaypoints(layoutEdges, layoutNodes, groupDeltas);
596
640
 
597
641
  // Compute total dimensions
598
642
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -657,6 +701,7 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
657
701
  edges: layoutEdges,
658
702
  groups: layoutGroups,
659
703
  options: computed.options,
704
+ direction: computed.direction,
660
705
  width: totalWidth,
661
706
  height: totalHeight,
662
707
  };
@@ -11,11 +11,8 @@ import { measureIndent } from '../utils/parsing';
11
11
  import type {
12
12
  ParsedInfra,
13
13
  InfraNode,
14
- InfraEdge,
15
14
  InfraGroup,
16
15
  InfraTagGroup,
17
- InfraTagValue,
18
- InfraProperty,
19
16
  } from './types';
20
17
  import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
21
18
 
@@ -116,7 +113,6 @@ export function parseInfra(content: string): ParsedInfra {
116
113
  };
117
114
 
118
115
  const nodeMap = new Map<string, InfraNode>();
119
- const edgeNodeId = 'edge';
120
116
 
121
117
  const setError = (line: number, message: string) => {
122
118
  const diag = makeDgmoError(line, message);
@@ -573,3 +569,49 @@ export function parseInfra(content: string): ParsedInfra {
573
569
 
574
570
  return result;
575
571
  }
572
+
573
+ // ============================================================
574
+ // Symbol extraction (for completion API)
575
+ // ============================================================
576
+
577
+ import type { DiagramSymbols } from '../completion';
578
+
579
+ /**
580
+ * Extract component names (entities) from infra document text.
581
+ * Used by the dgmo completion API for ghost hints and popup completions.
582
+ */
583
+ export function extractSymbols(docText: string): DiagramSymbols {
584
+ const entities: string[] = [];
585
+ let inMetadata = true;
586
+ let inTagGroup = false;
587
+ for (const rawLine of docText.split('\n')) {
588
+ const line = rawLine.trim();
589
+ if (line.length === 0) continue;
590
+ const indented = /^\s/.test(rawLine);
591
+
592
+ // Metadata phase: skip until first non-metadata root-level line.
593
+ // All lines (including indented) are skipped while inMetadata = true.
594
+ if (inMetadata) {
595
+ if (!indented && !/^[a-z-]+\s*:/i.test(line)) inMetadata = false;
596
+ else continue;
597
+ }
598
+
599
+ if (!indented) {
600
+ // Root-level: tag group declaration, group header, or component
601
+ if (/^tag\s*:/i.test(line)) { inTagGroup = true; continue; }
602
+ inTagGroup = false;
603
+ if (/^\[/.test(line)) continue; // group header
604
+ const m = COMPONENT_RE.exec(line);
605
+ if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
606
+ } else {
607
+ // Indented: skip tag values, connections, and properties; extract grouped components
608
+ if (inTagGroup) continue;
609
+ if (/^->/.test(line)) continue; // simple connection
610
+ if (/^-[^>]+-?>/.test(line)) continue; // labeled connection
611
+ if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value)
612
+ const m = COMPONENT_RE.exec(line);
613
+ if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
614
+ }
615
+ }
616
+ return { kind: 'infra', entities, keywords: [] };
617
+ }