@diagrammo/dgmo 0.8.21 → 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 +143 -93
- package/dist/editor.cjs +17 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +17 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +12 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +12 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +19997 -14886
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +331 -8
- package/dist/index.d.ts +331 -8
- package/dist/index.js +19984 -14889
- package/dist/index.js.map +1 -1
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +1 -0
- package/docs/language-reference.md +116 -6
- 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/layout.ts +309 -33
- package/src/boxes-and-lines/parser.ts +86 -10
- package/src/boxes-and-lines/renderer.ts +250 -91
- package/src/boxes-and-lines/types.ts +1 -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 +227 -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 +87 -8
- package/src/dgmo-router.ts +9 -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 +14 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/state-parser.ts +5 -10
- package/src/index.ts +50 -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 +31 -15
- package/src/mindmap/parser.ts +12 -18
- package/src/mindmap/renderer.ts +14 -13
- package/src/mindmap/text-wrap.ts +22 -12
- package/src/mindmap/types.ts +2 -2
- package/src/org/parser.ts +2 -6
- package/src/sequence/renderer.ts +144 -38
- package/src/sharing.ts +1 -0
- package/src/sitemap/layout.ts +21 -6
- 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/legend-layout.ts +3 -1
- package/src/utils/parsing.ts +46 -7
- package/src/utils/tag-groups.ts +46 -60
|
@@ -45,9 +45,9 @@ function measureIndent(line: string): number {
|
|
|
45
45
|
function parsePipeMetadata(
|
|
46
46
|
segment: string,
|
|
47
47
|
aliasMap: Map<string, string>
|
|
48
|
-
): { metadata: Record<string, string>; description?: string } {
|
|
48
|
+
): { metadata: Record<string, string>; description?: string[] } {
|
|
49
49
|
const metadata: Record<string, string> = {};
|
|
50
|
-
let description: string | undefined;
|
|
50
|
+
let description: string[] | undefined;
|
|
51
51
|
|
|
52
52
|
const items = segment.split(',');
|
|
53
53
|
for (const item of items) {
|
|
@@ -59,7 +59,7 @@ function parsePipeMetadata(
|
|
|
59
59
|
const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
60
60
|
const value = trimmed.slice(colonIdx + 1).trim();
|
|
61
61
|
if (rawKey === 'description') {
|
|
62
|
-
description = value;
|
|
62
|
+
description = [value];
|
|
63
63
|
} else {
|
|
64
64
|
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
65
65
|
metadata[resolvedKey] = value;
|
|
@@ -98,6 +98,25 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
98
98
|
let lastNodeLabel: string | null = null;
|
|
99
99
|
let lastSourceIsGroup = false;
|
|
100
100
|
|
|
101
|
+
// Description collection state
|
|
102
|
+
let descState: {
|
|
103
|
+
nodeLabel: string;
|
|
104
|
+
indent: number;
|
|
105
|
+
lines: string[];
|
|
106
|
+
edgeSeen: boolean;
|
|
107
|
+
} | null = null;
|
|
108
|
+
|
|
109
|
+
function flushDescription() {
|
|
110
|
+
if (descState && descState.lines.length > 0) {
|
|
111
|
+
const node = result.nodes.find((n) => n.label === descState!.nodeLabel);
|
|
112
|
+
if (node) {
|
|
113
|
+
const existing = node.description ?? [];
|
|
114
|
+
node.description = [...existing, ...descState!.lines];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
descState = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
// Group stack for nesting
|
|
102
121
|
interface GroupState {
|
|
103
122
|
group: BLGroup;
|
|
@@ -283,7 +302,51 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
283
302
|
|
|
284
303
|
// Non-indented line closes tag group
|
|
285
304
|
if (currentTagGroup && indent === 0) {
|
|
286
|
-
currentTagGroup = null;
|
|
305
|
+
currentTagGroup = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Description collection: indented non-edge lines under a node
|
|
309
|
+
if (descState !== null) {
|
|
310
|
+
if (indent > descState.indent) {
|
|
311
|
+
// Check if this is an edge line
|
|
312
|
+
if (trimmed.includes('->') || trimmed.includes('<->')) {
|
|
313
|
+
descState.edgeSeen = true;
|
|
314
|
+
// Fall through to normal edge processing
|
|
315
|
+
} else if (descState.edgeSeen) {
|
|
316
|
+
// Text after edges — emit warning
|
|
317
|
+
result.diagnostics.push(
|
|
318
|
+
makeDgmoError(
|
|
319
|
+
lineNum,
|
|
320
|
+
`Move description lines above edges for '${descState.nodeLabel}' — descriptions must come before -> lines`,
|
|
321
|
+
'warning'
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
continue;
|
|
325
|
+
} else if (
|
|
326
|
+
/^-\s*\w/.test(trimmed) &&
|
|
327
|
+
!trimmed.startsWith('- ') &&
|
|
328
|
+
!trimmed.includes('->') &&
|
|
329
|
+
!trimmed.includes('<->')
|
|
330
|
+
) {
|
|
331
|
+
// Looks like a malformed edge (e.g. "-Target" but not "- list item")
|
|
332
|
+
result.diagnostics.push(
|
|
333
|
+
makeDgmoError(
|
|
334
|
+
lineNum,
|
|
335
|
+
`Looks like an incomplete edge — did you mean "-> ${trimmed.slice(1).trim()}"?`,
|
|
336
|
+
'warning'
|
|
337
|
+
)
|
|
338
|
+
);
|
|
339
|
+
descState.lines.push(trimmed);
|
|
340
|
+
continue;
|
|
341
|
+
} else {
|
|
342
|
+
// Collect as description
|
|
343
|
+
descState.lines.push(trimmed);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
// Indent decreased — flush description
|
|
348
|
+
flushDescription();
|
|
349
|
+
}
|
|
287
350
|
}
|
|
288
351
|
|
|
289
352
|
// Close groups that are no longer scoped by indent
|
|
@@ -358,6 +421,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
358
421
|
if (groupMatch && !trimmed.includes('->') && !trimmed.includes('<->')) {
|
|
359
422
|
contentStarted = true;
|
|
360
423
|
currentTagGroup = null;
|
|
424
|
+
flushDescription();
|
|
361
425
|
const label = groupMatch[1];
|
|
362
426
|
|
|
363
427
|
// Check nesting depth
|
|
@@ -416,7 +480,18 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
416
480
|
|
|
417
481
|
// Indented shorthand: `-> Target` or `-label-> Target`
|
|
418
482
|
if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
|
|
419
|
-
|
|
483
|
+
// If the edge is at group-child indent level, use the containing group
|
|
484
|
+
const gs = currentGroupState();
|
|
485
|
+
const inGroup = gs && indent > gs.indent;
|
|
486
|
+
if (inGroup) {
|
|
487
|
+
const sourcePrefix = `[${gs.group.label}]`;
|
|
488
|
+
edgeText = `${sourcePrefix} ${trimmed}`;
|
|
489
|
+
} else if (lastNodeLabel) {
|
|
490
|
+
const sourcePrefix = lastSourceIsGroup
|
|
491
|
+
? `[${lastNodeLabel}]`
|
|
492
|
+
: lastNodeLabel;
|
|
493
|
+
edgeText = `${sourcePrefix} ${trimmed}`;
|
|
494
|
+
} else {
|
|
420
495
|
result.diagnostics.push(
|
|
421
496
|
makeDgmoError(
|
|
422
497
|
lineNum,
|
|
@@ -426,10 +501,6 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
426
501
|
);
|
|
427
502
|
continue;
|
|
428
503
|
}
|
|
429
|
-
const sourcePrefix = lastSourceIsGroup
|
|
430
|
-
? `[${lastNodeLabel}]`
|
|
431
|
-
: lastNodeLabel;
|
|
432
|
-
edgeText = `${sourcePrefix} ${trimmed}`;
|
|
433
504
|
}
|
|
434
505
|
|
|
435
506
|
const edge = parseEdgeLine(
|
|
@@ -449,6 +520,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
449
520
|
// Node: everything else
|
|
450
521
|
contentStarted = true;
|
|
451
522
|
currentTagGroup = null;
|
|
523
|
+
flushDescription(); // Flush any pending description from previous node
|
|
452
524
|
const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
|
|
453
525
|
if (!node) {
|
|
454
526
|
result.diagnostics.push(
|
|
@@ -486,8 +558,12 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
486
558
|
}
|
|
487
559
|
|
|
488
560
|
result.nodes.push(node);
|
|
561
|
+
descState = { nodeLabel: node.label, indent, lines: [], edgeSeen: false };
|
|
489
562
|
}
|
|
490
563
|
|
|
564
|
+
// Flush any remaining description
|
|
565
|
+
flushDescription();
|
|
566
|
+
|
|
491
567
|
// Close any remaining groups
|
|
492
568
|
while (groupStack.length > 0) {
|
|
493
569
|
const gs = groupStack.pop()!;
|
|
@@ -562,7 +638,7 @@ function parseNodeLine(
|
|
|
562
638
|
_diagnostics: DgmoError[]
|
|
563
639
|
): BLNode | null {
|
|
564
640
|
let metadata: Record<string, string> = {};
|
|
565
|
-
let description: string | undefined;
|
|
641
|
+
let description: string[] | undefined;
|
|
566
642
|
|
|
567
643
|
// Split on pipe for metadata
|
|
568
644
|
const pipeIdx = trimmed.indexOf('|');
|
|
@@ -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,6 +37,9 @@ 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;
|
|
@@ -81,13 +89,25 @@ function splitCamelCase(word: string): string[] {
|
|
|
81
89
|
return parts.length > 1 ? parts : [word];
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
|
|
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(
|
|
85
98
|
label: string,
|
|
86
99
|
nodeWidth: number,
|
|
87
|
-
|
|
100
|
+
maxLines: number
|
|
88
101
|
): { lines: string[]; fontSize: number } {
|
|
89
102
|
const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
|
|
90
|
-
|
|
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
|
+
}
|
|
91
111
|
|
|
92
112
|
for (
|
|
93
113
|
let fontSize = NODE_FONT_SIZE;
|
|
@@ -95,17 +115,15 @@ function fitTextToNode(
|
|
|
95
115
|
fontSize--
|
|
96
116
|
) {
|
|
97
117
|
const charWidth = fontSize * CHAR_WIDTH_RATIO;
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (maxCharsPerLine < 2 || maxLines < 1) continue;
|
|
101
|
-
if (label.length <= maxCharsPerLine) return { lines: [label], fontSize };
|
|
118
|
+
const maxChars = Math.floor(maxTextWidth / charWidth);
|
|
119
|
+
if (maxChars < 2) continue;
|
|
102
120
|
|
|
103
|
-
|
|
121
|
+
// Wrap words into lines
|
|
104
122
|
const lines: string[] = [];
|
|
105
123
|
let current = '';
|
|
106
124
|
for (const word of words) {
|
|
107
125
|
const test = current ? `${current} ${word}` : word;
|
|
108
|
-
if (test.length <=
|
|
126
|
+
if (test.length <= maxChars) {
|
|
109
127
|
current = test;
|
|
110
128
|
} else {
|
|
111
129
|
if (current) lines.push(current);
|
|
@@ -113,54 +131,39 @@ function fitTextToNode(
|
|
|
113
131
|
}
|
|
114
132
|
}
|
|
115
133
|
if (current) lines.push(current);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
) {
|
|
134
|
+
|
|
135
|
+
// All lines fit at this font? Done.
|
|
136
|
+
if (lines.length <= maxLines && lines.every((l) => l.length <= maxChars)) {
|
|
120
137
|
return { lines, fontSize };
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
const camelLines: string[] = [];
|
|
131
|
-
let cc = '';
|
|
132
|
-
for (const word of camelWords) {
|
|
133
|
-
const test = cc ? `${cc} ${word}` : word;
|
|
134
|
-
if (test.length <= maxCharsPerLine) {
|
|
135
|
-
cc = test;
|
|
136
|
-
} else {
|
|
137
|
-
if (cc) camelLines.push(cc);
|
|
138
|
-
cc = word;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (cc) camelLines.push(cc);
|
|
142
|
-
if (
|
|
143
|
-
camelLines.length <= maxLines &&
|
|
144
|
-
camelLines.every((l) => l.length <= maxCharsPerLine)
|
|
145
|
-
) {
|
|
146
|
-
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 };
|
|
147
146
|
}
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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';
|
|
158
160
|
}
|
|
159
|
-
|
|
161
|
+
return { lines: result, fontSize };
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
// Fallback at min font
|
|
162
165
|
const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
|
|
163
|
-
const maxChars = Math.floor(
|
|
166
|
+
const maxChars = Math.floor(maxTextWidth / charWidth);
|
|
164
167
|
const truncated =
|
|
165
168
|
label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
|
|
166
169
|
return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
|
|
@@ -292,6 +295,10 @@ interface BLRenderOptions {
|
|
|
292
295
|
exportDims?: { width?: number; height?: number };
|
|
293
296
|
activeTagGroup?: string | null;
|
|
294
297
|
hiddenTagValues?: Map<string, Set<string>>;
|
|
298
|
+
hideDescriptions?: boolean;
|
|
299
|
+
controlsExpanded?: boolean;
|
|
300
|
+
onToggleDescriptions?: (active: boolean) => void;
|
|
301
|
+
onToggleControlsExpand?: () => void;
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
export function renderBoxesAndLines(
|
|
@@ -302,8 +309,16 @@ export function renderBoxesAndLines(
|
|
|
302
309
|
isDark: boolean,
|
|
303
310
|
options?: BLRenderOptions
|
|
304
311
|
): void {
|
|
305
|
-
const {
|
|
306
|
-
|
|
312
|
+
const {
|
|
313
|
+
onClickItem,
|
|
314
|
+
exportDims,
|
|
315
|
+
activeTagGroup,
|
|
316
|
+
hiddenTagValues,
|
|
317
|
+
hideDescriptions,
|
|
318
|
+
controlsExpanded,
|
|
319
|
+
onToggleDescriptions,
|
|
320
|
+
onToggleControlsExpand,
|
|
321
|
+
} = options ?? {};
|
|
307
322
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
308
323
|
|
|
309
324
|
const width = exportDims?.width ?? container.clientWidth;
|
|
@@ -330,7 +345,12 @@ export function renderBoxesAndLines(
|
|
|
330
345
|
|
|
331
346
|
// Compute diagram bounds for scaling
|
|
332
347
|
const titleOffset = parsed.title ? 40 : 0;
|
|
333
|
-
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;
|
|
334
354
|
|
|
335
355
|
// Account for group label zone extensions (renderer-only, not in layout.height)
|
|
336
356
|
const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
|
|
@@ -727,7 +747,12 @@ export function renderBoxesAndLines(
|
|
|
727
747
|
}
|
|
728
748
|
|
|
729
749
|
if (onClickItem) {
|
|
730
|
-
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
|
+
});
|
|
731
756
|
}
|
|
732
757
|
|
|
733
758
|
// Rectangle card
|
|
@@ -748,45 +773,146 @@ export function renderBoxesAndLines(
|
|
|
748
773
|
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
749
774
|
|
|
750
775
|
// All text centered vertically using dominant-baseline: central
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
const
|
|
756
|
-
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
|
+
}
|
|
757
801
|
|
|
802
|
+
// Separator line (full width, matches infra style)
|
|
803
|
+
const sepY = -ln.height / 2 + headerH;
|
|
758
804
|
nodeG
|
|
759
|
-
.append('
|
|
760
|
-
.attr('
|
|
761
|
-
.attr('
|
|
762
|
-
.attr('
|
|
763
|
-
.attr('
|
|
764
|
-
.attr('
|
|
765
|
-
.attr('
|
|
766
|
-
.attr('
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
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)
|
|
771
819
|
);
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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);
|
|
787
907
|
}
|
|
788
908
|
} else {
|
|
789
|
-
|
|
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);
|
|
790
916
|
const lineH = fitted.fontSize * 1.3;
|
|
791
917
|
const totalH = fitted.lines.length * lineH;
|
|
792
918
|
for (let li = 0; li < fitted.lines.length; li++) {
|
|
@@ -805,13 +931,46 @@ export function renderBoxesAndLines(
|
|
|
805
931
|
}
|
|
806
932
|
|
|
807
933
|
// ── Render legend ──────────────────────────────────────
|
|
808
|
-
|
|
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
|
+
|
|
809
956
|
const legendConfig: LegendConfig = {
|
|
810
957
|
groups: parsed.tagGroups,
|
|
811
958
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
812
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
|
+
},
|
|
813
973
|
};
|
|
814
|
-
const legendState: LegendState = { activeGroup };
|
|
815
974
|
const legendG = svg
|
|
816
975
|
.append('g')
|
|
817
976
|
.attr('transform', `translate(0,${titleOffset + 4})`);
|
|
@@ -821,7 +980,7 @@ export function renderBoxesAndLines(
|
|
|
821
980
|
legendState,
|
|
822
981
|
palette,
|
|
823
982
|
isDark,
|
|
824
|
-
|
|
983
|
+
legendCallbacks,
|
|
825
984
|
width
|
|
826
985
|
);
|
|
827
986
|
legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
|