@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.
- package/.claude/commands/dgmo.md +76 -0
- package/dist/cli.cjs +164 -162
- package/dist/index.cjs +1146 -647
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -21
- package/dist/index.d.ts +9 -21
- package/dist/index.js +1146 -647
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +33 -50
- package/package.json +4 -3
- package/src/c4/layout.ts +75 -72
- package/src/c4/renderer.ts +122 -119
- package/src/cli.ts +130 -40
- package/src/d3.ts +55 -35
- package/src/echarts.ts +24 -24
- package/src/er/classify.ts +206 -0
- package/src/er/layout.ts +259 -94
- package/src/er/renderer.ts +246 -26
- package/src/index.ts +2 -2
- package/src/infra/compute.ts +1 -21
- package/src/infra/layout.ts +60 -13
- package/src/infra/parser.ts +5 -32
- package/src/infra/renderer.ts +403 -196
- package/src/infra/types.ts +1 -11
- package/src/initiative-status/layout.ts +46 -27
- package/src/kanban/renderer.ts +28 -24
- package/src/org/renderer.ts +24 -23
- package/src/render.ts +2 -2
- package/src/sequence/renderer.ts +24 -19
- package/src/sitemap/layout.ts +7 -14
- package/src/sitemap/renderer.ts +30 -29
- package/src/utils/legend-constants.ts +25 -0
- 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
|
@@ -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
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
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;
|
|
225
268
|
|
|
226
269
|
const svg = d3Selection
|
|
227
270
|
.select(container)
|
|
228
271
|
.append('svg')
|
|
229
|
-
.attr('width',
|
|
230
|
-
.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')
|
|
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',
|
|
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
|
|
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
|
|
449
|
-
const
|
|
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 =
|
|
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',
|
|
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',
|
|
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',
|
|
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,
|
|
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
|
|
259
|
+
export type { InfraLegendGroup } from './infra/renderer';
|
|
260
260
|
export type { CollapsedSitemapResult } from './sitemap/collapse';
|
|
261
261
|
|
|
262
262
|
export { collapseOrgTree } from './org/collapse';
|
package/src/infra/compute.ts
CHANGED
|
@@ -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
|
-
|
|
608
|
+
let effectiveNodes = parsed.nodes;
|
|
629
609
|
if (params.propertyOverrides) {
|
|
630
610
|
const propOv = params.propertyOverrides;
|
|
631
611
|
effectiveNodes = effectiveNodes.map((node) => {
|
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
|
};
|
package/src/infra/parser.ts
CHANGED
|
@@ -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
|
-
|
|
256
|
-
|
|
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;
|
|
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;
|
|
292
|
-
result.scenarios.push(scenario);
|
|
265
|
+
i = si - 1;
|
|
293
266
|
continue;
|
|
294
267
|
}
|
|
295
268
|
|