@diagrammo/dgmo 0.6.1 → 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.
- package/.claude/commands/dgmo.md +76 -0
- package/dist/cli.cjs +160 -159
- package/dist/index.cjs +780 -147
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +780 -147
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +33 -50
- package/package.json +4 -3
- package/src/c4/layout.ts +68 -5
- package/src/cli.ts +124 -2
- package/src/er/classify.ts +206 -0
- package/src/er/layout.ts +259 -94
- package/src/er/renderer.ts +231 -17
- package/src/infra/layout.ts +60 -13
- package/src/infra/renderer.ts +375 -32
- package/src/initiative-status/layout.ts +46 -30
- package/.claude/skills/dgmo-chart/SKILL.md +0 -141
- package/.claude/skills/dgmo-flowchart/SKILL.md +0 -61
- package/.claude/skills/dgmo-generate/SKILL.md +0 -59
- package/.claude/skills/dgmo-sequence/SKILL.md +0 -104
package/src/er/renderer.ts
CHANGED
|
@@ -13,12 +13,21 @@ 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_FONT_W,
|
|
21
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
22
|
+
LEGEND_ENTRY_TRAIL,
|
|
16
23
|
LEGEND_GROUP_GAP,
|
|
17
24
|
} from '../utils/legend-constants';
|
|
18
25
|
import type { ParsedERDiagram, ERConstraint } from './types';
|
|
19
26
|
import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
|
|
20
27
|
import { parseERDiagram } from './parser';
|
|
21
28
|
import { layoutERDiagram } from './layout';
|
|
29
|
+
import { classifyEREntities, ROLE_COLORS, ROLE_LABELS, ROLE_ORDER } from './classify';
|
|
30
|
+
import type { EntityRole } from './classify';
|
|
22
31
|
|
|
23
32
|
// ============================================================
|
|
24
33
|
// Constants
|
|
@@ -208,32 +217,62 @@ export function renderERDiagram(
|
|
|
208
217
|
isDark: boolean,
|
|
209
218
|
onClickItem?: (lineNumber: number) => void,
|
|
210
219
|
exportDims?: { width?: number; height?: number },
|
|
211
|
-
activeTagGroup?: string | null
|
|
220
|
+
activeTagGroup?: string | null,
|
|
221
|
+
/** When false, semantic role colors are suppressed and entities use a neutral color. */
|
|
222
|
+
semanticColorsActive?: boolean
|
|
212
223
|
): void {
|
|
213
224
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
214
225
|
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
226
|
+
const useSemanticColors =
|
|
227
|
+
parsed.tagGroups.length === 0 && layout.nodes.every((n) => !n.color);
|
|
228
|
+
const legendReserveH = useSemanticColors ? LEGEND_HEIGHT + DIAGRAM_PADDING : 0;
|
|
218
229
|
|
|
219
230
|
const titleHeight = parsed.title ? 40 : 0;
|
|
220
231
|
const diagramW = layout.width;
|
|
221
232
|
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
233
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
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;
|
|
231
268
|
|
|
232
269
|
const svg = d3Selection
|
|
233
270
|
.select(container)
|
|
234
271
|
.append('svg')
|
|
235
|
-
.attr('width',
|
|
236
|
-
.attr('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')
|
|
237
276
|
.style('font-family', FONT_FAMILY);
|
|
238
277
|
|
|
239
278
|
// ── Title ──
|
|
@@ -241,7 +280,7 @@ export function renderERDiagram(
|
|
|
241
280
|
const titleEl = svg
|
|
242
281
|
.append('text')
|
|
243
282
|
.attr('class', 'chart-title')
|
|
244
|
-
.attr('x',
|
|
283
|
+
.attr('x', viewW / 2)
|
|
245
284
|
.attr('y', 30)
|
|
246
285
|
.attr('text-anchor', 'middle')
|
|
247
286
|
.attr('fill', palette.text)
|
|
@@ -269,6 +308,15 @@ export function renderERDiagram(
|
|
|
269
308
|
// ── Auto-assign colors ──
|
|
270
309
|
const seriesColors = getSeriesColors(palette);
|
|
271
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
|
+
|
|
272
320
|
// ── Edges (behind nodes) ──
|
|
273
321
|
const useLabels = parsed.options.notation === 'labels';
|
|
274
322
|
|
|
@@ -347,7 +395,12 @@ export function renderERDiagram(
|
|
|
347
395
|
for (let ni = 0; ni < layout.nodes.length; ni++) {
|
|
348
396
|
const node = layout.nodes[ni];
|
|
349
397
|
const tagColor = resolveTagColor(node.metadata, parsed.tagGroups, activeTagGroup ?? null);
|
|
350
|
-
const
|
|
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];
|
|
351
404
|
|
|
352
405
|
const nodeG = contentG
|
|
353
406
|
.append('g')
|
|
@@ -365,6 +418,12 @@ export function renderERDiagram(
|
|
|
365
418
|
}
|
|
366
419
|
}
|
|
367
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
|
+
|
|
368
427
|
if (onClickItem) {
|
|
369
428
|
nodeG.style('cursor', 'pointer').on('click', () => {
|
|
370
429
|
onClickItem(node.lineNumber);
|
|
@@ -463,7 +522,7 @@ export function renderERDiagram(
|
|
|
463
522
|
}
|
|
464
523
|
|
|
465
524
|
let legendX = DIAGRAM_PADDING;
|
|
466
|
-
let legendY =
|
|
525
|
+
let legendY = viewH - DIAGRAM_PADDING;
|
|
467
526
|
|
|
468
527
|
for (const group of parsed.tagGroups) {
|
|
469
528
|
const groupG = legendG.append('g')
|
|
@@ -525,6 +584,161 @@ export function renderERDiagram(
|
|
|
525
584
|
legendX += LEGEND_GROUP_GAP;
|
|
526
585
|
}
|
|
527
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
|
+
}
|
|
528
742
|
}
|
|
529
743
|
|
|
530
744
|
// ============================================================
|
package/src/infra/layout.ts
CHANGED
|
@@ -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': '
|
|
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 =
|
|
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
|
-
):
|
|
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:
|
|
426
|
-
ranksep:
|
|
427
|
-
edgesep:
|
|
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
|
};
|