@diagrammo/dgmo 0.6.0 → 0.6.2

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.
@@ -9,10 +9,25 @@ import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { getSeriesColors } from '../palettes';
11
11
  import { resolveTagColor } from '../utils/tag-groups';
12
+ import {
13
+ LEGEND_HEIGHT,
14
+ LEGEND_PILL_PAD,
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_FONT_W,
21
+ LEGEND_ENTRY_DOT_GAP,
22
+ LEGEND_ENTRY_TRAIL,
23
+ LEGEND_GROUP_GAP,
24
+ } from '../utils/legend-constants';
12
25
  import type { ParsedERDiagram, ERConstraint } from './types';
13
26
  import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
14
27
  import { parseERDiagram } from './parser';
15
28
  import { layoutERDiagram } from './layout';
29
+ import { classifyEREntities, ROLE_COLORS, ROLE_LABELS, ROLE_ORDER } from './classify';
30
+ import type { EntityRole } from './classify';
16
31
 
17
32
  // ============================================================
18
33
  // Constants
@@ -202,32 +217,62 @@ export function renderERDiagram(
202
217
  isDark: boolean,
203
218
  onClickItem?: (lineNumber: number) => void,
204
219
  exportDims?: { width?: number; height?: number },
205
- activeTagGroup?: string | null
220
+ activeTagGroup?: string | null,
221
+ /** When false, semantic role colors are suppressed and entities use a neutral color. */
222
+ semanticColorsActive?: boolean
206
223
  ): void {
207
224
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
208
225
 
209
- const width = exportDims?.width ?? container.clientWidth;
210
- const height = exportDims?.height ?? container.clientHeight;
211
- if (width <= 0 || height <= 0) return;
226
+ const useSemanticColors =
227
+ parsed.tagGroups.length === 0 && layout.nodes.every((n) => !n.color);
228
+ const legendReserveH = useSemanticColors ? LEGEND_HEIGHT + DIAGRAM_PADDING : 0;
212
229
 
213
230
  const titleHeight = parsed.title ? 40 : 0;
214
231
  const diagramW = layout.width;
215
232
  const diagramH = layout.height;
216
- const availH = height - titleHeight;
217
- const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
218
- const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
219
- const scale = Math.min(MAX_SCALE, scaleX, scaleY);
220
233
 
221
- const scaledW = diagramW * scale;
222
- const scaledH = diagramH * scale;
223
- const offsetX = (width - scaledW) / 2;
224
- const offsetY = titleHeight + DIAGRAM_PADDING;
234
+ // Natural dimensions derived purely from the layout — no dependency on container
235
+ // size at render time, which eliminates the stagger caused by reading clientWidth/
236
+ // clientHeight before the container has stabilized.
237
+ const naturalW = diagramW + DIAGRAM_PADDING * 2;
238
+ const naturalH = diagramH + titleHeight + legendReserveH + DIAGRAM_PADDING * 2;
239
+
240
+ // For export: scale the natural layout to fit the requested pixel dimensions.
241
+ // For live preview: render at natural scale (scale=1) and let the SVG viewBox
242
+ // handle fitting to the container via CSS.
243
+ let viewW: number;
244
+ let viewH: number;
245
+ let scale: number;
246
+ let offsetX: number;
247
+ let offsetY: number;
248
+
249
+ if (exportDims) {
250
+ viewW = exportDims.width ?? naturalW;
251
+ viewH = exportDims.height ?? naturalH;
252
+ const availH = viewH - titleHeight - legendReserveH;
253
+ const scaleX = (viewW - DIAGRAM_PADDING * 2) / diagramW;
254
+ const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
255
+ scale = Math.min(MAX_SCALE, scaleX, scaleY);
256
+ const scaledW = diagramW * scale;
257
+ offsetX = (viewW - scaledW) / 2;
258
+ offsetY = titleHeight + DIAGRAM_PADDING;
259
+ } else {
260
+ viewW = naturalW;
261
+ viewH = naturalH;
262
+ scale = 1;
263
+ offsetX = DIAGRAM_PADDING;
264
+ offsetY = titleHeight + DIAGRAM_PADDING;
265
+ }
266
+
267
+ if (viewW <= 0 || viewH <= 0) return;
225
268
 
226
269
  const svg = d3Selection
227
270
  .select(container)
228
271
  .append('svg')
229
- .attr('width', width)
230
- .attr('height', height)
272
+ .attr('width', exportDims ? viewW : '100%')
273
+ .attr('height', exportDims ? viewH : '100%')
274
+ .attr('viewBox', `0 0 ${viewW} ${viewH}`)
275
+ .attr('preserveAspectRatio', 'xMidYMid meet')
231
276
  .style('font-family', FONT_FAMILY);
232
277
 
233
278
  // ── Title ──
@@ -235,7 +280,7 @@ export function renderERDiagram(
235
280
  const titleEl = svg
236
281
  .append('text')
237
282
  .attr('class', 'chart-title')
238
- .attr('x', width / 2)
283
+ .attr('x', viewW / 2)
239
284
  .attr('y', 30)
240
285
  .attr('text-anchor', 'middle')
241
286
  .attr('fill', palette.text)
@@ -263,6 +308,15 @@ export function renderERDiagram(
263
308
  // ── Auto-assign colors ──
264
309
  const seriesColors = getSeriesColors(palette);
265
310
 
311
+ // ── Semantic coloring gate ──
312
+ // Classify entities whenever conditions allow; suppress colors when user collapses the legend.
313
+ // (useSemanticColors was computed above for legend reserve height)
314
+ const semanticRoles: Map<string, EntityRole> | null = useSemanticColors
315
+ ? classifyEREntities(parsed.tables, parsed.relationships)
316
+ : null;
317
+ // semanticColorsActive defaults to true; false = legend collapsed, neutral color applied
318
+ const semanticActive = semanticRoles !== null && (semanticColorsActive ?? true);
319
+
266
320
  // ── Edges (behind nodes) ──
267
321
  const useLabels = parsed.options.notation === 'labels';
268
322
 
@@ -341,7 +395,12 @@ export function renderERDiagram(
341
395
  for (let ni = 0; ni < layout.nodes.length; ni++) {
342
396
  const node = layout.nodes[ni];
343
397
  const tagColor = resolveTagColor(node.metadata, parsed.tagGroups, activeTagGroup ?? null);
344
- const nodeColor = node.color ?? tagColor ?? seriesColors[ni % seriesColors.length];
398
+ const semanticColor = semanticActive
399
+ ? palette.colors[ROLE_COLORS[semanticRoles!.get(node.id) ?? 'unclassified']]
400
+ : semanticRoles
401
+ ? palette.primary // neutral color when legend is collapsed
402
+ : undefined;
403
+ const nodeColor = node.color ?? tagColor ?? semanticColor ?? seriesColors[ni % seriesColors.length];
345
404
 
346
405
  const nodeG = contentG
347
406
  .append('g')
@@ -359,6 +418,12 @@ export function renderERDiagram(
359
418
  }
360
419
  }
361
420
 
421
+ // Set data-er-role for semantic coloring (CSS targeting + test assertions)
422
+ if (semanticRoles) {
423
+ const role = semanticRoles.get(node.id);
424
+ if (role) nodeG.attr('data-er-role', role);
425
+ }
426
+
362
427
  if (onClickItem) {
363
428
  nodeG.style('cursor', 'pointer').on('click', () => {
364
429
  onClickItem(node.lineNumber);
@@ -445,19 +510,19 @@ export function renderERDiagram(
445
510
 
446
511
  // ── Tag Legend ──
447
512
  if (parsed.tagGroups.length > 0) {
448
- const LEGEND_Y_PAD = 16;
449
- const LEGEND_PILL_H = 22;
450
- const LEGEND_PILL_RX = 11;
451
- const LEGEND_PILL_PAD = 10;
513
+ const LEGEND_PILL_H = LEGEND_HEIGHT - 6;
514
+ const LEGEND_PILL_RX = Math.floor(LEGEND_PILL_H / 2);
452
515
  const LEGEND_GAP = 8;
453
- const LEGEND_FONT_SIZE = 11;
454
- const LEGEND_GROUP_GAP = 16;
455
516
 
456
517
  const legendG = svg.append('g')
457
518
  .attr('class', 'er-tag-legend');
458
519
 
520
+ if (activeTagGroup) {
521
+ legendG.attr('data-legend-active', activeTagGroup.toLowerCase());
522
+ }
523
+
459
524
  let legendX = DIAGRAM_PADDING;
460
- let legendY = height - DIAGRAM_PADDING;
525
+ let legendY = viewH - DIAGRAM_PADDING;
461
526
 
462
527
  for (const group of parsed.tagGroups) {
463
528
  const groupG = legendG.append('g')
@@ -469,7 +534,7 @@ export function renderERDiagram(
469
534
  .attr('y', legendY + LEGEND_PILL_H / 2)
470
535
  .attr('dominant-baseline', 'central')
471
536
  .attr('fill', palette.textMuted)
472
- .attr('font-size', LEGEND_FONT_SIZE)
537
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
473
538
  .attr('font-family', FONT_FAMILY)
474
539
  .text(`${group.name}:`);
475
540
 
@@ -484,7 +549,7 @@ export function renderERDiagram(
484
549
 
485
550
  // Estimate text width
486
551
  const tmpText = legendG.append('text')
487
- .attr('font-size', LEGEND_FONT_SIZE)
552
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
488
553
  .attr('font-family', FONT_FAMILY)
489
554
  .text(entry.value);
490
555
  const textW = tmpText.node()?.getComputedTextLength?.() ?? entry.value.length * 7;
@@ -509,7 +574,7 @@ export function renderERDiagram(
509
574
  .attr('text-anchor', 'middle')
510
575
  .attr('dominant-baseline', 'central')
511
576
  .attr('fill', palette.text)
512
- .attr('font-size', LEGEND_FONT_SIZE)
577
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
513
578
  .attr('font-family', FONT_FAMILY)
514
579
  .text(entry.value);
515
580
 
@@ -519,6 +584,161 @@ export function renderERDiagram(
519
584
  legendX += LEGEND_GROUP_GAP;
520
585
  }
521
586
  }
587
+
588
+ // ── Semantic Legend ──
589
+ // Rendered when semantic role detection is enabled (no tag groups, no explicit colors).
590
+ // Follows the sequence-legend pattern: one clickable "Role" group pill that expands
591
+ // to show colored-dot entries. Clicking toggles semanticColorsActive on/off.
592
+ if (semanticRoles) {
593
+ const presentRoles = ROLE_ORDER.filter((role) => {
594
+ for (const r of semanticRoles.values()) {
595
+ if (r === role) return true;
596
+ }
597
+ return false;
598
+ });
599
+
600
+ if (presentRoles.length > 0) {
601
+ // Measure actual text widths for consistent spacing regardless of character mix.
602
+ // Falls back to a character-count estimate in jsdom/test environments.
603
+ const measureLabelW = (text: string, fontSize: number): number => {
604
+ const dummy = svg.append('text')
605
+ .attr('font-size', fontSize)
606
+ .attr('font-family', FONT_FAMILY)
607
+ .attr('visibility', 'hidden')
608
+ .text(text);
609
+ const measured = (dummy.node() as SVGTextElement | null)?.getComputedTextLength?.() ?? 0;
610
+ dummy.remove();
611
+ return measured > 0 ? measured : text.length * fontSize * 0.6;
612
+ };
613
+
614
+ const labelWidths = new Map<EntityRole, number>();
615
+ for (const role of presentRoles) {
616
+ labelWidths.set(role, measureLabelW(ROLE_LABELS[role], LEGEND_ENTRY_FONT_SIZE));
617
+ }
618
+
619
+ const groupBg = isDark
620
+ ? mix(palette.surface, palette.bg, 50)
621
+ : mix(palette.surface, palette.bg, 30);
622
+
623
+ const groupName = 'Role';
624
+ const pillWidth = groupName.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
625
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
626
+
627
+ let totalWidth: number;
628
+ let entriesWidth = 0;
629
+ if (semanticActive) {
630
+ for (const role of presentRoles) {
631
+ entriesWidth +=
632
+ LEGEND_DOT_R * 2 +
633
+ LEGEND_ENTRY_DOT_GAP +
634
+ labelWidths.get(role)! +
635
+ LEGEND_ENTRY_TRAIL;
636
+ }
637
+ totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + LEGEND_ENTRY_TRAIL + entriesWidth;
638
+ } else {
639
+ totalWidth = pillWidth;
640
+ }
641
+
642
+ const legendX = (viewW - totalWidth) / 2;
643
+ const legendY = viewH - DIAGRAM_PADDING - LEGEND_HEIGHT;
644
+
645
+ const semanticLegendG = svg
646
+ .append('g')
647
+ .attr('class', 'er-semantic-legend')
648
+ .attr('data-legend-group', 'role')
649
+ .attr('transform', `translate(${legendX}, ${legendY})`)
650
+ .style('cursor', 'pointer');
651
+
652
+ if (semanticActive) {
653
+ // ── Expanded: outer capsule + inner pill + dot entries ──
654
+ semanticLegendG
655
+ .append('rect')
656
+ .attr('width', totalWidth)
657
+ .attr('height', LEGEND_HEIGHT)
658
+ .attr('rx', LEGEND_HEIGHT / 2)
659
+ .attr('fill', groupBg);
660
+
661
+ semanticLegendG
662
+ .append('rect')
663
+ .attr('x', LEGEND_CAPSULE_PAD)
664
+ .attr('y', LEGEND_CAPSULE_PAD)
665
+ .attr('width', pillWidth)
666
+ .attr('height', pillH)
667
+ .attr('rx', pillH / 2)
668
+ .attr('fill', palette.bg);
669
+
670
+ semanticLegendG
671
+ .append('rect')
672
+ .attr('x', LEGEND_CAPSULE_PAD)
673
+ .attr('y', LEGEND_CAPSULE_PAD)
674
+ .attr('width', pillWidth)
675
+ .attr('height', pillH)
676
+ .attr('rx', pillH / 2)
677
+ .attr('fill', 'none')
678
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
679
+ .attr('stroke-width', 0.75);
680
+
681
+ semanticLegendG
682
+ .append('text')
683
+ .attr('x', LEGEND_CAPSULE_PAD + pillWidth / 2)
684
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
685
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
686
+ .attr('font-weight', '500')
687
+ .attr('fill', palette.text)
688
+ .attr('text-anchor', 'middle')
689
+ .attr('font-family', FONT_FAMILY)
690
+ .text(groupName);
691
+
692
+ let entryX = LEGEND_CAPSULE_PAD + pillWidth + LEGEND_ENTRY_TRAIL;
693
+ for (const role of presentRoles) {
694
+ const label = ROLE_LABELS[role];
695
+ const roleColor = palette.colors[ROLE_COLORS[role]];
696
+
697
+ const entryG = semanticLegendG
698
+ .append('g')
699
+ .attr('data-legend-entry', role);
700
+
701
+ entryG
702
+ .append('circle')
703
+ .attr('cx', entryX + LEGEND_DOT_R)
704
+ .attr('cy', LEGEND_HEIGHT / 2)
705
+ .attr('r', LEGEND_DOT_R)
706
+ .attr('fill', roleColor);
707
+
708
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
709
+ entryG
710
+ .append('text')
711
+ .attr('x', textX)
712
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
713
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
714
+ .attr('fill', palette.textMuted)
715
+ .attr('font-family', FONT_FAMILY)
716
+ .text(label);
717
+
718
+ entryX = textX + labelWidths.get(role)! + LEGEND_ENTRY_TRAIL;
719
+ }
720
+ } else {
721
+ // ── Collapsed: single muted pill, no entries ──
722
+ semanticLegendG
723
+ .append('rect')
724
+ .attr('width', pillWidth)
725
+ .attr('height', LEGEND_HEIGHT)
726
+ .attr('rx', LEGEND_HEIGHT / 2)
727
+ .attr('fill', groupBg);
728
+
729
+ semanticLegendG
730
+ .append('text')
731
+ .attr('x', pillWidth / 2)
732
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
733
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
734
+ .attr('font-weight', '500')
735
+ .attr('fill', palette.textMuted)
736
+ .attr('text-anchor', 'middle')
737
+ .attr('font-family', FONT_FAMILY)
738
+ .text(groupName);
739
+ }
740
+ }
741
+ }
522
742
  }
523
743
 
524
744
  // ============================================================
package/src/index.ts CHANGED
@@ -246,7 +246,7 @@ export { collapseSitemapTree } from './sitemap/collapse';
246
246
 
247
247
  // ── Infra Chart ────────────────────────────────────────────
248
248
  export { parseInfra } from './infra/parser';
249
- export type { ParsedInfra, InfraNode, InfraEdge, InfraGroup, InfraTagGroup, InfraProperty, InfraDiagnostic, InfraScenario, InfraComputeParams, InfraBehaviorKey } from './infra/types';
249
+ export type { ParsedInfra, InfraNode, InfraEdge, InfraGroup, InfraTagGroup, InfraProperty, InfraDiagnostic, InfraComputeParams, InfraBehaviorKey } from './infra/types';
250
250
  export { INFRA_BEHAVIOR_KEYS } from './infra/types';
251
251
  export { computeInfra } from './infra/compute';
252
252
  export type { ComputedInfraModel, ComputedInfraNode, ComputedInfraEdge, InfraLatencyPercentiles, InfraAvailabilityPercentiles, InfraCbState } from './infra/types';
@@ -256,7 +256,7 @@ export type { InfraRole } from './infra/roles';
256
256
  export { layoutInfra } from './infra/layout';
257
257
  export type { InfraLayoutResult, InfraLayoutNode, InfraLayoutEdge, InfraLayoutGroup } from './infra/layout';
258
258
  export { renderInfra, parseAndLayoutInfra, computeInfraLegendGroups } from './infra/renderer';
259
- export type { InfraLegendGroup, InfraPlaybackState } from './infra/renderer';
259
+ export type { InfraLegendGroup } from './infra/renderer';
260
260
  export type { CollapsedSitemapResult } from './sitemap/collapse';
261
261
 
262
262
  export { collapseOrgTree } from './org/collapse';
@@ -604,28 +604,8 @@ export function computeInfra(
604
604
  const defaultLatencyMs = parseFloat(parsed.options['default-latency-ms'] ?? '') || 0;
605
605
  const defaultUptime = parseFloat(parsed.options['default-uptime'] ?? '') || 100;
606
606
 
607
- // Apply scenario overrides (shallow clone nodes with modified properties)
608
- let effectiveNodes = parsed.nodes;
609
- if (params.scenario) {
610
- const overrides = params.scenario.overrides;
611
- effectiveNodes = parsed.nodes.map((node) => {
612
- const nodeOverrides = overrides[node.id];
613
- if (!nodeOverrides) return node;
614
- const props = node.properties.map((p) => {
615
- const ov = nodeOverrides[p.key];
616
- return ov != null ? { ...p, value: ov } : p;
617
- });
618
- // Add new properties from scenario that don't exist on the node
619
- for (const [key, val] of Object.entries(nodeOverrides)) {
620
- if (!props.some((p) => p.key === key)) {
621
- props.push({ key, value: val, lineNumber: node.lineNumber });
622
- }
623
- }
624
- return { ...node, properties: props };
625
- });
626
- }
627
607
  // Apply per-node property overrides (from interactive sliders)
628
- // These take precedence over scenario overrides
608
+ let effectiveNodes = parsed.nodes;
629
609
  if (params.propertyOverrides) {
630
610
  const propOv = params.propertyOverrides;
631
611
  effectiveNodes = effectiveNodes.map((node) => {
@@ -73,6 +73,7 @@ export interface InfraLayoutResult {
73
73
  groups: InfraLayoutGroup[];
74
74
  /** Diagram-level options (e.g., default-latency-ms, default-uptime). */
75
75
  options: Record<string, string>;
76
+ direction: 'LR' | 'TB';
76
77
  width: number;
77
78
  height: number;
78
79
  }
@@ -110,12 +111,12 @@ const DISPLAY_KEYS = new Set([
110
111
 
111
112
  /** Display names for width estimation. */
112
113
  const DISPLAY_NAMES: Record<string, string> = {
113
- 'cache-hit': 'cache hit', 'firewall-block': 'fw block',
114
+ 'cache-hit': 'cache hit', 'firewall-block': 'firewall block',
114
115
  'ratelimit-rps': 'rate limit RPS', 'latency-ms': 'latency', 'uptime': 'uptime',
115
116
  'instances': 'instances', 'max-rps': 'max RPS',
116
- 'cb-error-threshold': 'CB error', 'cb-latency-threshold-ms': 'CB latency',
117
+ 'cb-error-threshold': 'CB error threshold', 'cb-latency-threshold-ms': 'CB latency threshold',
117
118
  'concurrency': 'concurrency', 'duration-ms': 'duration', 'cold-start-ms': 'cold start',
118
- 'buffer': 'buffer', 'drain-rate': 'drain', 'retention-hours': 'retention', 'partitions': 'partitions',
119
+ 'buffer': 'buffer', 'drain-rate': 'drain rate', 'retention-hours': 'retention', 'partitions': 'partitions',
119
120
  };
120
121
 
121
122
  function countDisplayProps(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
@@ -356,18 +357,20 @@ function formatUptime(fraction: number): string {
356
357
  // Group separation pass
357
358
  // ============================================================
358
359
 
359
- const GROUP_GAP = 24; // min clear gap between group boxes — matches GROUP_HEADER_HEIGHT
360
+ const GROUP_GAP = GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT; // min clear gap between group boxes
360
361
 
361
362
  export function separateGroups(
362
363
  groups: InfraLayoutGroup[],
363
364
  nodes: InfraLayoutNode[],
364
365
  isLR: boolean,
365
366
  maxIterations = 20,
366
- ): void {
367
+ ): Map<string, { dx: number; dy: number }> {
367
368
  // Symmetric 2D rectangle intersection — no sorting needed, handles all
368
369
  // relative positions correctly, stable after mid-pass shifts.
369
370
  // Endpoint edge routing is not affected: renderer.ts recomputes border
370
371
  // connection points from node x/y at render time via nodeBorderPoint().
372
+ const groupDeltas = new Map<string, { dx: number; dy: number }>();
373
+ let converged = false;
371
374
  for (let iter = 0; iter < maxIterations; iter++) {
372
375
  let anyOverlap = false;
373
376
  for (let i = 0; i < groups.length; i++) {
@@ -398,6 +401,11 @@ export function separateGroups(
398
401
  if (isLR) groupToShift.y += shift;
399
402
  else groupToShift.x += shift;
400
403
 
404
+ // Accumulate the total delta for this group (used by fixEdgeWaypoints)
405
+ const prev = groupDeltas.get(groupToShift.id) ?? { dx: 0, dy: 0 };
406
+ if (isLR) groupDeltas.set(groupToShift.id, { dx: prev.dx, dy: prev.dy + shift });
407
+ else groupDeltas.set(groupToShift.id, { dx: prev.dx + shift, dy: prev.dy });
408
+
401
409
  for (const node of nodes) {
402
410
  if (node.groupId === groupToShift.id) {
403
411
  if (isLR) node.y += shift;
@@ -406,7 +414,44 @@ export function separateGroups(
406
414
  }
407
415
  }
408
416
  }
409
- if (!anyOverlap) break;
417
+ if (!anyOverlap) { converged = true; break; }
418
+ }
419
+ if (!converged && maxIterations > 0) {
420
+ console.warn(`separateGroups: hit maxIterations (${maxIterations}) without fully resolving all group overlaps`);
421
+ }
422
+ return groupDeltas;
423
+ }
424
+
425
+ export function fixEdgeWaypoints(
426
+ edges: InfraLayoutEdge[],
427
+ nodes: InfraLayoutNode[],
428
+ groupDeltas: Map<string, { dx: number; dy: number }>,
429
+ ): void {
430
+ if (groupDeltas.size === 0) return;
431
+ const nodeToGroup = new Map<string, string | null>();
432
+ for (const node of nodes) nodeToGroup.set(node.id, node.groupId);
433
+
434
+ for (const edge of edges) {
435
+ const srcGroup = nodeToGroup.get(edge.sourceId) ?? null;
436
+ // Group-targeting edges (targetId is a group ID, not a node) return undefined from the map → null →
437
+ // treated as "ungrouped target", which is the correct approximation.
438
+ const tgtGroup = nodeToGroup.get(edge.targetId) ?? null;
439
+ const srcDelta = srcGroup ? groupDeltas.get(srcGroup) : undefined;
440
+ const tgtDelta = tgtGroup ? groupDeltas.get(tgtGroup) : undefined;
441
+
442
+ if (!srcDelta && !tgtDelta) continue; // neither side shifted
443
+
444
+ if (srcDelta && tgtDelta && srcGroup !== tgtGroup) {
445
+ // both sides in different shifted groups — discard, renderer draws a straight line
446
+ edge.points = [];
447
+ continue;
448
+ }
449
+
450
+ const delta = srcDelta ?? tgtDelta!;
451
+ for (const pt of edge.points) {
452
+ pt.x += delta.dx;
453
+ pt.y += delta.dy;
454
+ }
410
455
  }
411
456
  }
412
457
 
@@ -416,15 +461,16 @@ export function separateGroups(
416
461
 
417
462
  export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<string> | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
418
463
  if (computed.nodes.length === 0) {
419
- return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
464
+ return { nodes: [], edges: [], groups: [], options: {}, direction: computed.direction, width: 0, height: 0 };
420
465
  }
421
466
 
467
+ const isLR = computed.direction !== 'TB';
422
468
  const g = new dagre.graphlib.Graph();
423
469
  g.setGraph({
424
470
  rankdir: computed.direction === 'TB' ? 'TB' : 'LR',
425
- nodesep: 50,
426
- ranksep: 100,
427
- edgesep: 20,
471
+ nodesep: isLR ? 70 : 60,
472
+ ranksep: isLR ? 150 : 120,
473
+ edgesep: 30,
428
474
  });
429
475
  g.setDefaultEdgeLabel(() => ({}));
430
476
 
@@ -436,7 +482,6 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
436
482
 
437
483
  // Extra space dagre must reserve for the group bounding box
438
484
  const GROUP_INFLATE = GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT;
439
- const isLR = computed.direction !== 'TB';
440
485
 
441
486
  // Add nodes — inflate grouped nodes so dagre accounts for group boxes
442
487
  const widthMap = new Map<string, number>();
@@ -591,8 +636,9 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
591
636
  };
592
637
  });
593
638
 
594
- // Separate overlapping groups (post-layout pass)
595
- separateGroups(layoutGroups, layoutNodes, isLR);
639
+ // Separate overlapping groups (post-layout pass) and fix stale edge waypoints
640
+ const groupDeltas = separateGroups(layoutGroups, layoutNodes, isLR);
641
+ fixEdgeWaypoints(layoutEdges, layoutNodes, groupDeltas);
596
642
 
597
643
  // Compute total dimensions
598
644
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -657,6 +703,7 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
657
703
  edges: layoutEdges,
658
704
  groups: layoutGroups,
659
705
  options: computed.options,
706
+ direction: computed.direction,
660
707
  width: totalWidth,
661
708
  height: totalHeight,
662
709
  };
@@ -110,7 +110,6 @@ export function parseInfra(content: string): ParsedInfra {
110
110
  edges: [],
111
111
  groups: [],
112
112
  tagGroups: [],
113
- scenarios: [],
114
113
  options: {},
115
114
  diagnostics: [],
116
115
  error: null,
@@ -250,46 +249,20 @@ export function parseInfra(content: string): ParsedInfra {
250
249
  continue;
251
250
  }
252
251
 
253
- // scenario: Name
252
+ // scenario: Name — deprecated, emit warning and skip block
254
253
  if (/^scenario\s*:/i.test(trimmed)) {
255
- finishCurrentNode();
256
- finishCurrentTagGroup();
257
- currentGroup = null;
258
- const scenarioName = trimmed.replace(/^scenario\s*:\s*/i, '').trim();
259
- const scenario: import('./types').InfraScenario = {
260
- name: scenarioName,
261
- overrides: {},
262
- lineNumber,
263
- };
264
- // Parse indented block for scenario overrides
265
- let scenarioNodeId: string | null = null;
254
+ console.warn('[dgmo warn] scenario syntax is deprecated and will be ignored');
255
+ // Skip indented block
266
256
  let si = i + 1;
267
257
  while (si < lines.length) {
268
258
  const sLine = lines[si];
269
259
  const sTrimmed = sLine.trim();
270
260
  if (!sTrimmed || sTrimmed.startsWith('#')) { si++; continue; }
271
261
  const sIndent = sLine.length - sLine.trimStart().length;
272
- if (sIndent === 0) break; // back to top level
273
-
274
- if (sIndent <= 2) {
275
- // Node reference (e.g., " edge" or " API")
276
- scenarioNodeId = nodeId(sTrimmed.replace(/\|.*$/, '').trim());
277
- if (!scenario.overrides[scenarioNodeId]) {
278
- scenario.overrides[scenarioNodeId] = {};
279
- }
280
- } else if (scenarioNodeId) {
281
- // Property override (e.g., " rps: 10000")
282
- const pm = sTrimmed.match(PROPERTY_RE);
283
- if (pm) {
284
- const key = pm[1].toLowerCase();
285
- const val = parsePropertyValue(pm[2].trim());
286
- scenario.overrides[scenarioNodeId][key] = val;
287
- }
288
- }
262
+ if (sIndent === 0) break;
289
263
  si++;
290
264
  }
291
- i = si - 1; // advance past scenario block
292
- result.scenarios.push(scenario);
265
+ i = si - 1;
293
266
  continue;
294
267
  }
295
268