@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.
- package/.claude/commands/dgmo.md +294 -0
- package/AGENTS.md +148 -0
- package/dist/cli.cjs +338 -163
- package/dist/index.cjs +1080 -319
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -4
- package/dist/index.d.ts +28 -4
- package/dist/index.js +1078 -319
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +33 -50
- package/package.json +8 -5
- package/src/c4/layout.ts +68 -10
- package/src/c4/parser.ts +0 -16
- package/src/c4/renderer.ts +1 -5
- package/src/class/layout.ts +0 -1
- package/src/class/parser.ts +28 -0
- package/src/class/renderer.ts +5 -26
- package/src/cli.ts +673 -2
- package/src/completion.ts +58 -0
- package/src/d3.ts +58 -106
- package/src/dgmo-router.ts +0 -57
- package/src/echarts.ts +96 -55
- package/src/er/classify.ts +206 -0
- package/src/er/layout.ts +259 -94
- package/src/er/parser.ts +30 -1
- package/src/er/renderer.ts +231 -18
- package/src/graph/flowchart-parser.ts +27 -4
- package/src/graph/flowchart-renderer.ts +1 -2
- package/src/graph/state-parser.ts +0 -1
- package/src/graph/state-renderer.ts +1 -3
- package/src/index.ts +10 -0
- package/src/infra/compute.ts +0 -7
- package/src/infra/layout.ts +60 -15
- package/src/infra/parser.ts +46 -4
- package/src/infra/renderer.ts +376 -47
- package/src/initiative-status/layout.ts +46 -30
- package/src/initiative-status/renderer.ts +5 -25
- package/src/kanban/parser.ts +0 -2
- package/src/org/layout.ts +0 -4
- package/src/org/renderer.ts +7 -28
- package/src/sequence/parser.ts +14 -11
- package/src/sequence/renderer.ts +0 -2
- package/src/sequence/tag-resolution.ts +0 -1
- package/src/sitemap/layout.ts +1 -14
- package/src/sitemap/parser.ts +1 -2
- package/src/sitemap/renderer.ts +0 -3
- package/src/utils/arrows.ts +7 -7
- package/src/utils/export-container.ts +40 -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
|
@@ -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
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
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',
|
|
236
|
-
.attr('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',
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
// ============================================================
|
package/src/infra/compute.ts
CHANGED
|
@@ -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);
|
package/src/infra/layout.ts
CHANGED
|
@@ -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': '
|
|
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 =
|
|
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
|
-
):
|
|
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:
|
|
426
|
-
ranksep:
|
|
427
|
-
edgesep:
|
|
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
|
};
|
package/src/infra/parser.ts
CHANGED
|
@@ -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
|
+
}
|