@diagrammo/dgmo 0.8.20 → 0.8.22
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/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +142 -90
- package/dist/editor.cjs +30 -4
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +30 -4
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +25 -3
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +25 -3
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +21201 -12886
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +646 -89
- package/dist/index.d.ts +646 -89
- package/dist/index.js +21178 -12889
- package/dist/index.js.map +1 -1
- package/docs/guide/chart-mindmap.md +198 -0
- package/docs/guide/chart-sequence.md +23 -1
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/chart-wireframe.md +100 -0
- package/docs/guide/index.md +8 -0
- package/docs/guide/registry.json +1 -0
- package/docs/language-reference.md +249 -4
- package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
- package/gallery/fixtures/c4-full.dgmo +2 -2
- package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
- package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
- package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
- package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
- package/gallery/fixtures/gantt-full.dgmo +2 -2
- package/gallery/fixtures/gantt.dgmo +2 -2
- package/gallery/fixtures/infra-full.dgmo +2 -2
- package/gallery/fixtures/infra.dgmo +1 -1
- package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
- package/gallery/fixtures/sequence-tags.dgmo +2 -2
- package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
- package/gallery/fixtures/tech-radar.dgmo +36 -0
- package/gallery/fixtures/timeline.dgmo +1 -1
- package/package.json +1 -1
- package/src/boxes-and-lines/collapse.ts +21 -3
- package/src/boxes-and-lines/layout.ts +360 -42
- package/src/boxes-and-lines/parser.ts +94 -11
- package/src/boxes-and-lines/renderer.ts +371 -114
- package/src/boxes-and-lines/types.ts +2 -1
- package/src/c4/layout.ts +8 -8
- package/src/c4/parser.ts +35 -2
- package/src/c4/renderer.ts +19 -3
- package/src/c4/types.ts +1 -0
- package/src/chart.ts +14 -7
- package/src/completion.ts +253 -0
- package/src/cycle/layout.ts +732 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +539 -0
- package/src/cycle/types.ts +77 -0
- package/src/d3.ts +240 -40
- package/src/dgmo-router.ts +15 -0
- package/src/echarts.ts +7 -4
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +26 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/layout.ts +73 -9
- package/src/graph/state-collapse.ts +78 -0
- package/src/graph/state-parser.ts +5 -10
- package/src/graph/state-renderer.ts +139 -34
- package/src/index.ts +78 -0
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +30 -6
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1456 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +325 -63
- package/src/mindmap/collapse.ts +88 -0
- package/src/mindmap/layout.ts +605 -0
- package/src/mindmap/parser.ts +373 -0
- package/src/mindmap/renderer.ts +544 -0
- package/src/mindmap/text-wrap.ts +217 -0
- package/src/mindmap/types.ts +55 -0
- package/src/org/parser.ts +2 -6
- package/src/render.ts +18 -21
- package/src/sequence/renderer.ts +273 -56
- package/src/sharing.ts +3 -0
- package/src/sitemap/layout.ts +56 -18
- package/src/sitemap/parser.ts +26 -17
- package/src/sitemap/renderer.ts +34 -0
- package/src/sitemap/types.ts +1 -0
- package/src/tech-radar/index.ts +14 -0
- package/src/tech-radar/interactive.ts +1058 -0
- package/src/tech-radar/layout.ts +190 -0
- package/src/tech-radar/parser.ts +385 -0
- package/src/tech-radar/renderer.ts +1159 -0
- package/src/tech-radar/shared.ts +187 -0
- package/src/tech-radar/types.ts +81 -0
- package/src/utils/description-helpers.ts +33 -0
- package/src/utils/export-container.ts +3 -2
- package/src/utils/legend-d3.ts +1 -0
- package/src/utils/legend-layout.ts +5 -3
- package/src/utils/parsing.ts +48 -7
- package/src/utils/tag-groups.ts +46 -60
- package/src/wireframe/layout.ts +460 -0
- package/src/wireframe/parser.ts +956 -0
- package/src/wireframe/renderer.ts +1293 -0
- package/src/wireframe/types.ts +110 -0
|
@@ -7,7 +7,12 @@ import * as d3Shape from 'd3-shape';
|
|
|
7
7
|
import { FONT_FAMILY } from '../fonts';
|
|
8
8
|
import { LEGEND_HEIGHT } from '../utils/legend-constants';
|
|
9
9
|
import { renderLegendD3 } from '../utils/legend-d3';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
LegendConfig,
|
|
12
|
+
LegendState,
|
|
13
|
+
LegendCallbacks,
|
|
14
|
+
ControlsGroupToggle,
|
|
15
|
+
} from '../utils/legend-types';
|
|
11
16
|
import {
|
|
12
17
|
TITLE_FONT_SIZE,
|
|
13
18
|
TITLE_FONT_WEIGHT,
|
|
@@ -17,6 +22,7 @@ import { contrastText, mix } from '../palettes/color-utils';
|
|
|
17
22
|
import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
|
|
18
23
|
import type { TagGroup } from '../utils/tag-groups';
|
|
19
24
|
import type { PaletteColors } from '../palettes';
|
|
25
|
+
import { renderInlineText } from '../utils/inline-markdown';
|
|
20
26
|
import type { ParsedBoxesAndLines, BLNode } from './types';
|
|
21
27
|
import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
|
|
22
28
|
|
|
@@ -24,7 +30,6 @@ import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
|
|
|
24
30
|
const DIAGRAM_PADDING = 20;
|
|
25
31
|
const NODE_FONT_SIZE = 13;
|
|
26
32
|
const MIN_NODE_FONT_SIZE = 9;
|
|
27
|
-
const META_FONT_SIZE = 10;
|
|
28
33
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
29
34
|
const EDGE_STROKE_WIDTH = 1.5;
|
|
30
35
|
const NODE_STROKE_WIDTH = 1.5;
|
|
@@ -32,10 +37,14 @@ const NODE_RX = 8;
|
|
|
32
37
|
const COLLAPSE_BAR_HEIGHT = 4;
|
|
33
38
|
const ARROWHEAD_W = 5;
|
|
34
39
|
const ARROWHEAD_H = 4;
|
|
40
|
+
const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
|
|
41
|
+
const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px font (matches infra META_LINE_HEIGHT)
|
|
42
|
+
const MAX_DESC_LINES = 6;
|
|
35
43
|
const CHAR_WIDTH_RATIO = 0.6;
|
|
36
44
|
const NODE_TEXT_PADDING = 12;
|
|
37
45
|
const GROUP_RX = 8;
|
|
38
46
|
const GROUP_LABEL_FONT_SIZE = 14;
|
|
47
|
+
const GROUP_LABEL_ZONE = 32;
|
|
39
48
|
|
|
40
49
|
type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
41
50
|
type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
|
|
@@ -45,19 +54,13 @@ const lineGeneratorLR = d3Shape
|
|
|
45
54
|
.line<{ x: number; y: number }>()
|
|
46
55
|
.x((d) => d.x)
|
|
47
56
|
.y((d) => d.y)
|
|
48
|
-
.curve(d3Shape.
|
|
57
|
+
.curve(d3Shape.curveBasis);
|
|
49
58
|
|
|
50
59
|
const lineGeneratorTB = d3Shape
|
|
51
60
|
.line<{ x: number; y: number }>()
|
|
52
61
|
.x((d) => d.x)
|
|
53
62
|
.y((d) => d.y)
|
|
54
|
-
.curve(d3Shape.
|
|
55
|
-
|
|
56
|
-
const lineGeneratorLinear = d3Shape
|
|
57
|
-
.line<{ x: number; y: number }>()
|
|
58
|
-
.x((d) => d.x)
|
|
59
|
-
.y((d) => d.y)
|
|
60
|
-
.curve(d3Shape.curveLinear);
|
|
63
|
+
.curve(d3Shape.curveBasis);
|
|
61
64
|
|
|
62
65
|
// ── Text fitting ───────────────────────────────────────────
|
|
63
66
|
|
|
@@ -86,13 +89,25 @@ function splitCamelCase(word: string): string[] {
|
|
|
86
89
|
return parts.length > 1 ? parts : [word];
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Fit a label into a header zone for described nodes.
|
|
94
|
+
* Strategy: split first (spaces, dashes, camelCase), wrap into lines,
|
|
95
|
+
* shrink font if needed, truncate individual lines with "…" — never hard-break.
|
|
96
|
+
*/
|
|
97
|
+
function fitLabelToHeader(
|
|
90
98
|
label: string,
|
|
91
99
|
nodeWidth: number,
|
|
92
|
-
|
|
100
|
+
maxLines: number
|
|
93
101
|
): { lines: string[]; fontSize: number } {
|
|
94
102
|
const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
|
|
95
|
-
|
|
103
|
+
|
|
104
|
+
// Split on spaces and dashes, then camelCase split each part
|
|
105
|
+
const rawParts = label.split(/(\s+|-)/);
|
|
106
|
+
const words: string[] = [];
|
|
107
|
+
for (const part of rawParts) {
|
|
108
|
+
if (!part || /^\s+$/.test(part) || part === '-') continue;
|
|
109
|
+
words.push(...splitCamelCase(part));
|
|
110
|
+
}
|
|
96
111
|
|
|
97
112
|
for (
|
|
98
113
|
let fontSize = NODE_FONT_SIZE;
|
|
@@ -100,17 +115,15 @@ function fitTextToNode(
|
|
|
100
115
|
fontSize--
|
|
101
116
|
) {
|
|
102
117
|
const charWidth = fontSize * CHAR_WIDTH_RATIO;
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
if (maxCharsPerLine < 2 || maxLines < 1) continue;
|
|
106
|
-
if (label.length <= maxCharsPerLine) return { lines: [label], fontSize };
|
|
118
|
+
const maxChars = Math.floor(maxTextWidth / charWidth);
|
|
119
|
+
if (maxChars < 2) continue;
|
|
107
120
|
|
|
108
|
-
|
|
121
|
+
// Wrap words into lines
|
|
109
122
|
const lines: string[] = [];
|
|
110
123
|
let current = '';
|
|
111
124
|
for (const word of words) {
|
|
112
125
|
const test = current ? `${current} ${word}` : word;
|
|
113
|
-
if (test.length <=
|
|
126
|
+
if (test.length <= maxChars) {
|
|
114
127
|
current = test;
|
|
115
128
|
} else {
|
|
116
129
|
if (current) lines.push(current);
|
|
@@ -118,54 +131,39 @@ function fitTextToNode(
|
|
|
118
131
|
}
|
|
119
132
|
}
|
|
120
133
|
if (current) lines.push(current);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
) {
|
|
134
|
+
|
|
135
|
+
// All lines fit at this font? Done.
|
|
136
|
+
if (lines.length <= maxLines && lines.every((l) => l.length <= maxChars)) {
|
|
125
137
|
return { lines, fontSize };
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
const camelLines: string[] = [];
|
|
136
|
-
let cc = '';
|
|
137
|
-
for (const word of camelWords) {
|
|
138
|
-
const test = cc ? `${cc} ${word}` : word;
|
|
139
|
-
if (test.length <= maxCharsPerLine) {
|
|
140
|
-
cc = test;
|
|
141
|
-
} else {
|
|
142
|
-
if (cc) camelLines.push(cc);
|
|
143
|
-
cc = word;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
if (cc) camelLines.push(cc);
|
|
147
|
-
if (
|
|
148
|
-
camelLines.length <= maxLines &&
|
|
149
|
-
camelLines.every((l) => l.length <= maxCharsPerLine)
|
|
150
|
-
) {
|
|
151
|
-
return { lines: camelLines, fontSize };
|
|
140
|
+
// Lines fit in count but some are too wide? Truncate those lines.
|
|
141
|
+
if (lines.length <= maxLines) {
|
|
142
|
+
const result = lines.map((l) =>
|
|
143
|
+
l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
|
|
144
|
+
);
|
|
145
|
+
return { lines: result, fontSize };
|
|
152
146
|
}
|
|
153
147
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
148
|
+
// Too many lines — take first maxLines, truncate last + any oversized
|
|
149
|
+
const result = lines
|
|
150
|
+
.slice(0, maxLines)
|
|
151
|
+
.map((l) =>
|
|
152
|
+
l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
|
|
153
|
+
);
|
|
154
|
+
const last = result[maxLines - 1];
|
|
155
|
+
if (!last.endsWith('\u2026')) {
|
|
156
|
+
result[maxLines - 1] =
|
|
157
|
+
last.length >= maxChars
|
|
158
|
+
? last.slice(0, maxChars - 1) + '\u2026'
|
|
159
|
+
: last + '\u2026';
|
|
163
160
|
}
|
|
164
|
-
|
|
161
|
+
return { lines: result, fontSize };
|
|
165
162
|
}
|
|
166
163
|
|
|
164
|
+
// Fallback at min font
|
|
167
165
|
const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
|
|
168
|
-
const maxChars = Math.floor(
|
|
166
|
+
const maxChars = Math.floor(maxTextWidth / charWidth);
|
|
169
167
|
const truncated =
|
|
170
168
|
label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
|
|
171
169
|
return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
|
|
@@ -297,6 +295,10 @@ interface BLRenderOptions {
|
|
|
297
295
|
exportDims?: { width?: number; height?: number };
|
|
298
296
|
activeTagGroup?: string | null;
|
|
299
297
|
hiddenTagValues?: Map<string, Set<string>>;
|
|
298
|
+
hideDescriptions?: boolean;
|
|
299
|
+
controlsExpanded?: boolean;
|
|
300
|
+
onToggleDescriptions?: (active: boolean) => void;
|
|
301
|
+
onToggleControlsExpand?: () => void;
|
|
300
302
|
}
|
|
301
303
|
|
|
302
304
|
export function renderBoxesAndLines(
|
|
@@ -307,8 +309,16 @@ export function renderBoxesAndLines(
|
|
|
307
309
|
isDark: boolean,
|
|
308
310
|
options?: BLRenderOptions
|
|
309
311
|
): void {
|
|
310
|
-
const {
|
|
311
|
-
|
|
312
|
+
const {
|
|
313
|
+
onClickItem,
|
|
314
|
+
exportDims,
|
|
315
|
+
activeTagGroup,
|
|
316
|
+
hiddenTagValues,
|
|
317
|
+
hideDescriptions,
|
|
318
|
+
controlsExpanded,
|
|
319
|
+
onToggleDescriptions,
|
|
320
|
+
onToggleControlsExpand,
|
|
321
|
+
} = options ?? {};
|
|
312
322
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
313
323
|
|
|
314
324
|
const width = exportDims?.width ?? container.clientWidth;
|
|
@@ -335,9 +345,24 @@ export function renderBoxesAndLines(
|
|
|
335
345
|
|
|
336
346
|
// Compute diagram bounds for scaling
|
|
337
347
|
const titleOffset = parsed.title ? 40 : 0;
|
|
338
|
-
const
|
|
348
|
+
const hasAnyDescriptions = parsed.nodes.some(
|
|
349
|
+
(n) => n.description && n.description.length > 0
|
|
350
|
+
);
|
|
351
|
+
const needsLegend =
|
|
352
|
+
parsed.tagGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
|
|
353
|
+
const legendH = needsLegend ? LEGEND_HEIGHT + 8 : 0;
|
|
354
|
+
|
|
355
|
+
// Account for group label zone extensions (renderer-only, not in layout.height)
|
|
356
|
+
const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
|
|
357
|
+
let labelZoneExtension = 0;
|
|
358
|
+
for (const group of parsed.groups) {
|
|
359
|
+
if (group.children.some((c) => groupLabelsSet.has(c))) {
|
|
360
|
+
labelZoneExtension += GROUP_LABEL_ZONE;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
339
364
|
const contentW = layout.width;
|
|
340
|
-
const contentH = layout.height + titleOffset + legendH;
|
|
365
|
+
const contentH = layout.height + titleOffset + legendH + labelZoneExtension;
|
|
341
366
|
|
|
342
367
|
const scaleX = width / (contentW + DIAGRAM_PADDING * 2);
|
|
343
368
|
const scaleY = height / (contentH + DIAGRAM_PADDING * 2);
|
|
@@ -390,10 +415,29 @@ export function renderBoxesAndLines(
|
|
|
390
415
|
}
|
|
391
416
|
ensureArrowMarkers(defs, arrowColors);
|
|
392
417
|
|
|
393
|
-
// ── Render groups (bottom layer)
|
|
394
|
-
|
|
418
|
+
// ── Render groups (bottom layer, largest first for nesting) ──
|
|
419
|
+
const sortedGroups = [...layout.groups].sort(
|
|
420
|
+
(a, b) => b.width * b.height - a.width * a.height
|
|
421
|
+
);
|
|
422
|
+
// Identify groups that contain sub-groups — only those need extra label space
|
|
423
|
+
const groupLabels = new Set(layout.groups.map((g) => g.label));
|
|
424
|
+
const hasSubGroups = new Set<string>();
|
|
425
|
+
for (const group of parsed.groups) {
|
|
426
|
+
for (const child of group.children) {
|
|
427
|
+
if (groupLabels.has(child)) hasSubGroups.add(group.label);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const group of sortedGroups) {
|
|
395
432
|
const gx = group.x - group.width / 2;
|
|
396
|
-
|
|
433
|
+
// Only extend top for groups that contain sub-groups (dagre under-pads these)
|
|
434
|
+
const needsExtra = !group.collapsed && hasSubGroups.has(group.label);
|
|
435
|
+
const gy = needsExtra
|
|
436
|
+
? group.y - group.height / 2 - GROUP_LABEL_ZONE
|
|
437
|
+
: group.y - group.height / 2;
|
|
438
|
+
const groupHeight = needsExtra
|
|
439
|
+
? group.height + GROUP_LABEL_ZONE
|
|
440
|
+
: group.height;
|
|
397
441
|
|
|
398
442
|
const groupG = diagramG
|
|
399
443
|
.append('g')
|
|
@@ -464,7 +508,7 @@ export function renderBoxesAndLines(
|
|
|
464
508
|
.attr('x', gx)
|
|
465
509
|
.attr('y', gy)
|
|
466
510
|
.attr('width', group.width)
|
|
467
|
-
.attr('height',
|
|
511
|
+
.attr('height', groupHeight)
|
|
468
512
|
.attr('rx', GROUP_RX)
|
|
469
513
|
.attr('ry', GROUP_RX)
|
|
470
514
|
.attr('fill', mix(palette.surface, palette.bg, 40))
|
|
@@ -516,8 +560,73 @@ export function renderBoxesAndLines(
|
|
|
516
560
|
if (isHidden) continue;
|
|
517
561
|
}
|
|
518
562
|
|
|
519
|
-
//
|
|
520
|
-
|
|
563
|
+
// Self-loop: render as a smooth circular arc below the node
|
|
564
|
+
if (le.source === le.target) {
|
|
565
|
+
const nodeLayout = layoutNodeMap.get(le.source);
|
|
566
|
+
if (nodeLayout) {
|
|
567
|
+
const edgeG = diagramG
|
|
568
|
+
.append('g')
|
|
569
|
+
.attr('class', 'bl-edge-group')
|
|
570
|
+
.attr('data-line-number', String(le.lineNumber));
|
|
571
|
+
edgeGroups.set(i, edgeG as unknown as D3G);
|
|
572
|
+
|
|
573
|
+
const markerId = `bl-arrow-${color.replace('#', '')}`;
|
|
574
|
+
const cx = nodeLayout.x;
|
|
575
|
+
const cy = nodeLayout.y;
|
|
576
|
+
const hw = nodeLayout.width / 2;
|
|
577
|
+
const hh = nodeLayout.height / 2;
|
|
578
|
+
const pad = 20; // clearance from node edge
|
|
579
|
+
|
|
580
|
+
// Arc exits from bottom of right side, swings wide, returns to right of bottom side
|
|
581
|
+
const startX = cx + hw;
|
|
582
|
+
const startY = cy + hh * 0.4;
|
|
583
|
+
const endX = cx + hw * 0.4;
|
|
584
|
+
const endY = cy + hh;
|
|
585
|
+
|
|
586
|
+
// Control points swing far out to create a smooth circular arc
|
|
587
|
+
const cp1x = startX + hw + pad;
|
|
588
|
+
const cp1y = startY;
|
|
589
|
+
const cp2x = endX;
|
|
590
|
+
const cp2y = endY + hh + pad;
|
|
591
|
+
|
|
592
|
+
edgeG
|
|
593
|
+
.append('path')
|
|
594
|
+
.attr('class', 'bl-edge')
|
|
595
|
+
.attr(
|
|
596
|
+
'd',
|
|
597
|
+
`M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
|
|
598
|
+
)
|
|
599
|
+
.attr('fill', 'none')
|
|
600
|
+
.attr('stroke', color)
|
|
601
|
+
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
602
|
+
.attr('marker-end', `url(#${markerId})`);
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Parallel edge fan: construct explicit 5-point geometry so lines
|
|
608
|
+
// bundle at ports and visibly spread apart in the middle.
|
|
609
|
+
let points: { x: number; y: number }[];
|
|
610
|
+
if (le.yOffset !== 0 && le.parallelCount > 1) {
|
|
611
|
+
const srcLayout = layoutNodeMap.get(le.source);
|
|
612
|
+
const tgtLayout = layoutNodeMap.get(le.target);
|
|
613
|
+
const srcY = srcLayout?.y ?? le.points[0]?.y ?? 0;
|
|
614
|
+
const tgtY = tgtLayout?.y ?? le.points[le.points.length - 1]?.y ?? 0;
|
|
615
|
+
const srcX = le.points[0]?.x ?? 0;
|
|
616
|
+
const tgtX = le.points[le.points.length - 1]?.x ?? 0;
|
|
617
|
+
const midX = (srcX + tgtX) / 2;
|
|
618
|
+
const midY = (srcY + tgtY) / 2;
|
|
619
|
+
|
|
620
|
+
points = [
|
|
621
|
+
{ x: srcX, y: srcY }, // port (bundled)
|
|
622
|
+
{ x: srcX + (midX - srcX) * 0.3, y: srcY + le.yOffset * 0.5 }, // separate
|
|
623
|
+
{ x: midX, y: midY + le.yOffset }, // full spread
|
|
624
|
+
{ x: tgtX - (tgtX - midX) * 0.3, y: tgtY + le.yOffset * 0.5 }, // converge
|
|
625
|
+
{ x: tgtX, y: tgtY }, // port (bundled)
|
|
626
|
+
];
|
|
627
|
+
} else {
|
|
628
|
+
points = le.points.map((p) => ({ x: p.x, y: p.y }));
|
|
629
|
+
}
|
|
521
630
|
if (points.length < 2) continue;
|
|
522
631
|
|
|
523
632
|
const edgeG = diagramG
|
|
@@ -527,11 +636,7 @@ export function renderBoxesAndLines(
|
|
|
527
636
|
edgeGroups.set(i, edgeG as unknown as D3G);
|
|
528
637
|
|
|
529
638
|
const markerId = `bl-arrow-${color.replace('#', '')}`;
|
|
530
|
-
const gen =
|
|
531
|
-
? lineGeneratorLinear
|
|
532
|
-
: parsed.direction === 'TB'
|
|
533
|
-
? lineGeneratorTB
|
|
534
|
-
: lineGeneratorLR;
|
|
639
|
+
const gen = parsed.direction === 'TB' ? lineGeneratorTB : lineGeneratorLR;
|
|
535
640
|
const path = edgeG
|
|
536
641
|
.append('path')
|
|
537
642
|
.attr('class', 'bl-edge')
|
|
@@ -546,14 +651,25 @@ export function renderBoxesAndLines(
|
|
|
546
651
|
path.attr('marker-start', `url(#${revId})`);
|
|
547
652
|
}
|
|
548
653
|
|
|
549
|
-
// Edge label
|
|
654
|
+
// Edge label — for parallel edges, place relative to each line:
|
|
655
|
+
// negative offset (top line) → label above, zero → on line, positive → below
|
|
550
656
|
if (le.label && le.labelX != null && le.labelY != null) {
|
|
551
657
|
const lw = le.label.length * EDGE_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO;
|
|
658
|
+
const labelH = EDGE_LABEL_FONT_SIZE + 6;
|
|
659
|
+
let ly: number;
|
|
660
|
+
if (le.parallelCount > 1 && le.yOffset !== 0) {
|
|
661
|
+
// Position label on the line at midpoint, shifted above/below based on offset sign
|
|
662
|
+
const lineY = le.labelY + 10 + le.yOffset; // +10 to undo the -10 in layout
|
|
663
|
+
const labelShift = le.yOffset < 0 ? -labelH : labelH;
|
|
664
|
+
ly = lineY + labelShift * 0.5;
|
|
665
|
+
} else {
|
|
666
|
+
ly = le.labelY + le.yOffset;
|
|
667
|
+
}
|
|
552
668
|
labelPositions.push({
|
|
553
669
|
x: le.labelX,
|
|
554
|
-
y:
|
|
670
|
+
y: ly,
|
|
555
671
|
width: lw + 8,
|
|
556
|
-
height:
|
|
672
|
+
height: labelH,
|
|
557
673
|
idx: i,
|
|
558
674
|
});
|
|
559
675
|
}
|
|
@@ -631,7 +747,12 @@ export function renderBoxesAndLines(
|
|
|
631
747
|
}
|
|
632
748
|
|
|
633
749
|
if (onClickItem) {
|
|
634
|
-
nodeG.on('click', () =>
|
|
750
|
+
nodeG.on('click', (event: Event) => {
|
|
751
|
+
// Don't intercept clicks on links in description text
|
|
752
|
+
const target = event.target as Element | null;
|
|
753
|
+
if (target?.closest('a')) return;
|
|
754
|
+
onClickItem(node.lineNumber);
|
|
755
|
+
});
|
|
635
756
|
}
|
|
636
757
|
|
|
637
758
|
// Rectangle card
|
|
@@ -652,45 +773,146 @@ export function renderBoxesAndLines(
|
|
|
652
773
|
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
653
774
|
|
|
654
775
|
// All text centered vertically using dominant-baseline: central
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const
|
|
659
|
-
const
|
|
660
|
-
const
|
|
776
|
+
const desc = node.description;
|
|
777
|
+
if (desc && desc.length > 0 && !hideDescriptions) {
|
|
778
|
+
// Label in header zone — split on spaces/dashes/camelCase, up to 3 lines
|
|
779
|
+
const MAX_LABEL_LINES = 3;
|
|
780
|
+
const fitted = fitLabelToHeader(node.label, ln.width, MAX_LABEL_LINES);
|
|
781
|
+
const labelLines = fitted.lines;
|
|
782
|
+
const labelLineH = fitted.fontSize * 1.3;
|
|
783
|
+
const labelTotalH = labelLines.length * labelLineH;
|
|
784
|
+
const headerH = labelTotalH + 12; // 12px padding
|
|
785
|
+
const headerCenterY = -ln.height / 2 + headerH / 2;
|
|
786
|
+
for (let li = 0; li < labelLines.length; li++) {
|
|
787
|
+
nodeG
|
|
788
|
+
.append('text')
|
|
789
|
+
.attr('x', 0)
|
|
790
|
+
.attr(
|
|
791
|
+
'y',
|
|
792
|
+
headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
|
|
793
|
+
)
|
|
794
|
+
.attr('text-anchor', 'middle')
|
|
795
|
+
.attr('dominant-baseline', 'central')
|
|
796
|
+
.attr('font-size', fitted.fontSize)
|
|
797
|
+
.attr('font-weight', '600')
|
|
798
|
+
.attr('fill', colors.text)
|
|
799
|
+
.text(labelLines[li]);
|
|
800
|
+
}
|
|
661
801
|
|
|
802
|
+
// Separator line (full width, matches infra style)
|
|
803
|
+
const sepY = -ln.height / 2 + headerH;
|
|
662
804
|
nodeG
|
|
663
|
-
.append('
|
|
664
|
-
.attr('
|
|
665
|
-
.attr('
|
|
666
|
-
.attr('
|
|
667
|
-
.attr('
|
|
668
|
-
.attr('
|
|
669
|
-
.attr('
|
|
670
|
-
.attr('
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
|
|
805
|
+
.append('line')
|
|
806
|
+
.attr('x1', -ln.width / 2)
|
|
807
|
+
.attr('y1', sepY)
|
|
808
|
+
.attr('x2', ln.width / 2)
|
|
809
|
+
.attr('y2', sepY)
|
|
810
|
+
.attr('stroke', colors.stroke)
|
|
811
|
+
.attr('stroke-opacity', 0.3)
|
|
812
|
+
.attr('stroke-width', 1);
|
|
813
|
+
|
|
814
|
+
// Description lines with word wrapping and inline markdown
|
|
815
|
+
const descStartY = sepY + 4 + DESC_FONT_SIZE;
|
|
816
|
+
const maxTextWidth = ln.width - NODE_TEXT_PADDING * 2;
|
|
817
|
+
const charsPerLine = Math.floor(
|
|
818
|
+
maxTextWidth / (DESC_FONT_SIZE * CHAR_WIDTH_RATIO)
|
|
675
819
|
);
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
820
|
+
const descLineH = DESC_FONT_SIZE * DESC_LINE_HEIGHT;
|
|
821
|
+
|
|
822
|
+
// Estimate display length — strip markdown syntax for measurement
|
|
823
|
+
const displayLen = (text: string): number =>
|
|
824
|
+
text
|
|
825
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
|
|
826
|
+
.replace(/\*\*(.+?)\*\*/g, '$1') // **bold** → bold
|
|
827
|
+
.replace(/\*(.+?)\*/g, '$1') // *italic* → italic
|
|
828
|
+
.replace(/`(.+?)`/g, '$1') // `code` → code
|
|
829
|
+
.replace(/https?:\/\/\S+/g, (u) => u.slice(0, 20)).length; // bare URLs shortened
|
|
830
|
+
const hasMarkdown = (text: string): boolean =>
|
|
831
|
+
/\[.+?\]\(.+?\)|https?:\/\/|www\./.test(text);
|
|
832
|
+
|
|
833
|
+
// Build wrapped lines from description
|
|
834
|
+
const wrappedLines: string[] = [];
|
|
835
|
+
for (let descLine of desc) {
|
|
836
|
+
// Render `- ` as bullet
|
|
837
|
+
if (descLine.startsWith('- ')) descLine = '\u2022 ' + descLine.slice(2);
|
|
838
|
+
// Normalize bare URLs: `http example.com` → `http://example.com`
|
|
839
|
+
descLine = descLine.replace(
|
|
840
|
+
/\bhttps?\s+([\w][\w.-]+\.[a-z]{2,}(?:\/\S*)?)/gi,
|
|
841
|
+
(_, domain) => `https://${domain}`
|
|
842
|
+
);
|
|
843
|
+
if (displayLen(descLine) <= charsPerLine) {
|
|
844
|
+
wrappedLines.push(descLine);
|
|
845
|
+
} else {
|
|
846
|
+
// Word wrap using display lengths
|
|
847
|
+
// Keep bullet attached to first word
|
|
848
|
+
let words: string[];
|
|
849
|
+
if (descLine.startsWith('\u2022 ')) {
|
|
850
|
+
const rest = descLine.slice(2);
|
|
851
|
+
const restWords = rest.split(/\s+/);
|
|
852
|
+
words = [`\u2022 ${restWords[0]}`, ...restWords.slice(1)];
|
|
853
|
+
} else {
|
|
854
|
+
words = descLine.split(/\s+/);
|
|
855
|
+
}
|
|
856
|
+
let current = '';
|
|
857
|
+
for (const word of words) {
|
|
858
|
+
const test = current ? `${current} ${word}` : word;
|
|
859
|
+
if (displayLen(test) <= charsPerLine) {
|
|
860
|
+
current = test;
|
|
861
|
+
} else {
|
|
862
|
+
if (current) wrappedLines.push(current);
|
|
863
|
+
// Don't truncate words containing markdown/links
|
|
864
|
+
current =
|
|
865
|
+
!hasMarkdown(word) && word.length > charsPerLine
|
|
866
|
+
? word.slice(0, charsPerLine - 1) + '\u2026'
|
|
867
|
+
: word;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (current) wrappedLines.push(current);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const truncated = wrappedLines.length > MAX_DESC_LINES;
|
|
875
|
+
const visibleLines = truncated
|
|
876
|
+
? wrappedLines.slice(0, MAX_DESC_LINES)
|
|
877
|
+
: wrappedLines;
|
|
878
|
+
|
|
879
|
+
for (let li = 0; li < visibleLines.length; li++) {
|
|
880
|
+
let lineText = visibleLines[li];
|
|
881
|
+
// Truncate last line if there are more lines beyond the cap
|
|
882
|
+
if (truncated && li === visibleLines.length - 1) {
|
|
883
|
+
lineText =
|
|
884
|
+
lineText.length >= charsPerLine
|
|
885
|
+
? lineText.slice(0, charsPerLine - 1) + '\u2026'
|
|
886
|
+
: lineText + '\u2026';
|
|
887
|
+
}
|
|
888
|
+
// Bulleted lines left-align, plain lines center
|
|
889
|
+
const isBullet = lineText.startsWith('\u2022');
|
|
890
|
+
const textEl = nodeG
|
|
891
|
+
.append('text')
|
|
892
|
+
.attr('x', isBullet ? -ln.width / 2 + 6 : 0)
|
|
893
|
+
.attr('y', descStartY + li * descLineH)
|
|
894
|
+
.attr('text-anchor', isBullet ? 'start' : 'middle')
|
|
895
|
+
.attr('dominant-baseline', 'central')
|
|
896
|
+
.attr('font-size', DESC_FONT_SIZE)
|
|
897
|
+
.attr('fill', palette.textMuted);
|
|
898
|
+
renderInlineText(textEl, lineText, palette, DESC_FONT_SIZE);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Tooltip when truncated
|
|
902
|
+
if (truncated) {
|
|
903
|
+
const fullText = desc.join(' ');
|
|
904
|
+
const tooltipText =
|
|
905
|
+
fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
|
|
906
|
+
nodeG.append('title').text(tooltipText);
|
|
691
907
|
}
|
|
692
908
|
} else {
|
|
693
|
-
|
|
909
|
+
// Compact label — use same split-first algorithm (camelCase, no hard-break)
|
|
910
|
+
// 16px vertical padding (8 top + 8 bottom) to keep text off borders
|
|
911
|
+
const maxLabelLines = Math.max(
|
|
912
|
+
2,
|
|
913
|
+
Math.floor((ln.height - 16) / (MIN_NODE_FONT_SIZE * 1.3))
|
|
914
|
+
);
|
|
915
|
+
const fitted = fitLabelToHeader(node.label, ln.width, maxLabelLines);
|
|
694
916
|
const lineH = fitted.fontSize * 1.3;
|
|
695
917
|
const totalH = fitted.lines.length * lineH;
|
|
696
918
|
for (let li = 0; li < fitted.lines.length; li++) {
|
|
@@ -709,13 +931,46 @@ export function renderBoxesAndLines(
|
|
|
709
931
|
}
|
|
710
932
|
|
|
711
933
|
// ── Render legend ──────────────────────────────────────
|
|
712
|
-
|
|
934
|
+
const hasDescriptions = parsed.nodes.some(
|
|
935
|
+
(n) => n.description && n.description.length > 0
|
|
936
|
+
);
|
|
937
|
+
const hasLegend = parsed.tagGroups.length > 0 || hasDescriptions;
|
|
938
|
+
|
|
939
|
+
if (hasLegend) {
|
|
940
|
+
// Build controls group for description toggle
|
|
941
|
+
let controlsGroup: { toggles: ControlsGroupToggle[] } | undefined;
|
|
942
|
+
if (hasDescriptions && onToggleDescriptions) {
|
|
943
|
+
controlsGroup = {
|
|
944
|
+
toggles: [
|
|
945
|
+
{
|
|
946
|
+
id: 'descriptions',
|
|
947
|
+
type: 'toggle',
|
|
948
|
+
label: 'Descriptions',
|
|
949
|
+
active: !hideDescriptions,
|
|
950
|
+
onToggle: () => {},
|
|
951
|
+
},
|
|
952
|
+
],
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
713
956
|
const legendConfig: LegendConfig = {
|
|
714
957
|
groups: parsed.tagGroups,
|
|
715
958
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
716
959
|
mode: 'fixed',
|
|
960
|
+
controlsGroup,
|
|
961
|
+
};
|
|
962
|
+
const legendState: LegendState = {
|
|
963
|
+
activeGroup,
|
|
964
|
+
controlsExpanded,
|
|
965
|
+
};
|
|
966
|
+
const legendCallbacks: LegendCallbacks = {
|
|
967
|
+
onControlsExpand: onToggleControlsExpand,
|
|
968
|
+
onControlsToggle: (toggleId, active) => {
|
|
969
|
+
if (toggleId === 'descriptions' && onToggleDescriptions) {
|
|
970
|
+
onToggleDescriptions(active);
|
|
971
|
+
}
|
|
972
|
+
},
|
|
717
973
|
};
|
|
718
|
-
const legendState: LegendState = { activeGroup };
|
|
719
974
|
const legendG = svg
|
|
720
975
|
.append('g')
|
|
721
976
|
.attr('transform', `translate(0,${titleOffset + 4})`);
|
|
@@ -725,7 +980,7 @@ export function renderBoxesAndLines(
|
|
|
725
980
|
legendState,
|
|
726
981
|
palette,
|
|
727
982
|
isDark,
|
|
728
|
-
|
|
983
|
+
legendCallbacks,
|
|
729
984
|
width
|
|
730
985
|
);
|
|
731
986
|
legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
|
|
@@ -743,10 +998,12 @@ export function renderBoxesAndLinesForExport(
|
|
|
743
998
|
options?: {
|
|
744
999
|
exportDims?: { width: number; height: number };
|
|
745
1000
|
activeTagGroup?: string | null;
|
|
1001
|
+
hiddenTagValues?: Map<string, Set<string>>;
|
|
746
1002
|
}
|
|
747
1003
|
): void {
|
|
748
1004
|
renderBoxesAndLines(container, parsed, layout, palette, isDark, {
|
|
749
1005
|
exportDims: options?.exportDims,
|
|
750
1006
|
activeTagGroup: options?.activeTagGroup,
|
|
1007
|
+
hiddenTagValues: options?.hiddenTagValues,
|
|
751
1008
|
});
|
|
752
1009
|
}
|