@diagrammo/dgmo 0.8.21 → 0.8.23
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 +145 -93
- package/dist/editor.cjs +20 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +20 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +15 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +15 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +20843 -14937
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +426 -17
- package/dist/index.d.ts +426 -17
- package/dist/index.js +20795 -14912
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +380 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +179 -0
- package/dist/internal.d.ts +179 -0
- package/dist/internal.js +337 -0
- package/dist/internal.js.map +1 -0
- package/docs/guide/chart-cycle.md +156 -0
- package/docs/guide/chart-journey-map.md +179 -0
- package/docs/guide/chart-pyramid.md +111 -0
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +6 -0
- package/docs/language-reference.md +177 -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/pyramid/dikw.dgmo +17 -0
- package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
- package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
- 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 +11 -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/cli.ts +5 -35
- package/src/completion.ts +233 -41
- package/src/cycle/layout.ts +723 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +566 -0
- package/src/cycle/types.ts +98 -0
- package/src/d3.ts +107 -8
- package/src/dgmo-router.ts +82 -3
- package/src/echarts.ts +8 -5
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +17 -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 +63 -2
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +32 -8
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/internal.ts +16 -0
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1521 -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/collapse.ts +81 -0
- package/src/org/parser.ts +2 -6
- package/src/org/renderer.ts +212 -4
- package/src/pyramid/parser.ts +172 -0
- package/src/pyramid/renderer.ts +684 -0
- package/src/pyramid/types.ts +28 -0
- package/src/render.ts +2 -8
- package/src/sequence/parser.ts +62 -20
- package/src/sequence/renderer.ts +146 -40
- 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 +1112 -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 +47 -7
- package/src/utils/tag-groups.ts +46 -60
|
@@ -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);
|
package/src/c4/layout.ts
CHANGED
|
@@ -689,7 +689,7 @@ export function computeC4NodeDimensions(
|
|
|
689
689
|
// (no type label — containers are the default in container view)
|
|
690
690
|
let height = CARD_V_PAD + NAME_HEIGHT;
|
|
691
691
|
|
|
692
|
-
const desc = el.
|
|
692
|
+
const desc = el.description?.join('\n');
|
|
693
693
|
if (desc) {
|
|
694
694
|
const contentWidth = width - CARD_H_PAD * 2;
|
|
695
695
|
const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
|
|
@@ -719,7 +719,7 @@ export function computeC4NodeDimensions(
|
|
|
719
719
|
// Context card layout: type + name | divider | description
|
|
720
720
|
let height = CARD_V_PAD + TYPE_LABEL_HEIGHT + DIVIDER_GAP + NAME_HEIGHT;
|
|
721
721
|
|
|
722
|
-
const desc = el.
|
|
722
|
+
const desc = el.description?.join('\n');
|
|
723
723
|
if (desc) {
|
|
724
724
|
const contentWidth = width - CARD_H_PAD * 2;
|
|
725
725
|
const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
|
|
@@ -887,7 +887,7 @@ export function layoutC4Context(
|
|
|
887
887
|
id: el.name,
|
|
888
888
|
name: el.name,
|
|
889
889
|
type: el.type as 'person' | 'system',
|
|
890
|
-
description: el.
|
|
890
|
+
description: el.description?.join('\n'),
|
|
891
891
|
metadata: el.metadata,
|
|
892
892
|
lineNumber: el.lineNumber,
|
|
893
893
|
color,
|
|
@@ -1241,7 +1241,7 @@ export function layoutC4Containers(
|
|
|
1241
1241
|
id: el.name,
|
|
1242
1242
|
name: el.name,
|
|
1243
1243
|
type: 'container',
|
|
1244
|
-
description: el.
|
|
1244
|
+
description: el.description?.join('\n'),
|
|
1245
1245
|
metadata: el.metadata,
|
|
1246
1246
|
lineNumber: el.lineNumber,
|
|
1247
1247
|
color,
|
|
@@ -1267,7 +1267,7 @@ export function layoutC4Containers(
|
|
|
1267
1267
|
id: el.name,
|
|
1268
1268
|
name: el.name,
|
|
1269
1269
|
type: el.type as 'person' | 'system',
|
|
1270
|
-
description: el.
|
|
1270
|
+
description: el.description?.join('\n'),
|
|
1271
1271
|
metadata: el.metadata,
|
|
1272
1272
|
lineNumber: el.lineNumber,
|
|
1273
1273
|
color,
|
|
@@ -1787,7 +1787,7 @@ export function layoutC4Components(
|
|
|
1787
1787
|
id: el.name,
|
|
1788
1788
|
name: el.name,
|
|
1789
1789
|
type: 'component',
|
|
1790
|
-
description: el.
|
|
1790
|
+
description: el.description?.join('\n'),
|
|
1791
1791
|
metadata: el.metadata,
|
|
1792
1792
|
lineNumber: el.lineNumber,
|
|
1793
1793
|
color,
|
|
@@ -1813,7 +1813,7 @@ export function layoutC4Components(
|
|
|
1813
1813
|
id: el.name,
|
|
1814
1814
|
name: el.name,
|
|
1815
1815
|
type: el.type as 'person' | 'system' | 'container',
|
|
1816
|
-
description: el.
|
|
1816
|
+
description: el.description?.join('\n'),
|
|
1817
1817
|
metadata: el.metadata,
|
|
1818
1818
|
lineNumber: el.lineNumber,
|
|
1819
1819
|
color,
|
|
@@ -2236,7 +2236,7 @@ export function layoutC4Deployment(
|
|
|
2236
2236
|
id: r.element.name,
|
|
2237
2237
|
name: r.element.name,
|
|
2238
2238
|
type: 'container',
|
|
2239
|
-
description: r.element.
|
|
2239
|
+
description: r.element.description?.join('\n'),
|
|
2240
2240
|
metadata: r.element.metadata,
|
|
2241
2241
|
lineNumber: r.element.lineNumber,
|
|
2242
2242
|
color,
|
package/src/c4/parser.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
parseFirstLine,
|
|
21
21
|
OPTION_NOCOLON_RE,
|
|
22
22
|
} from '../utils/parsing';
|
|
23
|
+
import { tryStripDescriptionKeyword } from '../utils/description-helpers';
|
|
23
24
|
import type {
|
|
24
25
|
ParsedC4,
|
|
25
26
|
C4Element,
|
|
@@ -695,11 +696,20 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
|
|
|
695
696
|
explicitShape ??
|
|
696
697
|
inferC4Shape(namePart, metadata.tech ?? metadata.technology);
|
|
697
698
|
|
|
699
|
+
// Extract description from pipe metadata into dedicated field
|
|
700
|
+
let isADescription: string[] | undefined;
|
|
701
|
+
if ('description' in metadata) {
|
|
702
|
+
const descVal = metadata['description'].trim();
|
|
703
|
+
if (descVal) isADescription = [descVal];
|
|
704
|
+
delete metadata['description'];
|
|
705
|
+
}
|
|
706
|
+
|
|
698
707
|
const element: C4Element = {
|
|
699
708
|
name: namePart,
|
|
700
709
|
type: elementType,
|
|
701
710
|
shape,
|
|
702
711
|
metadata,
|
|
712
|
+
description: isADescription,
|
|
703
713
|
children: [],
|
|
704
714
|
groups: [],
|
|
705
715
|
relationships: [],
|
|
@@ -762,11 +772,20 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
|
|
|
762
772
|
explicitShape ??
|
|
763
773
|
inferC4Shape(namePart, metadata.tech ?? metadata.technology);
|
|
764
774
|
|
|
775
|
+
// Extract description from pipe metadata into dedicated field
|
|
776
|
+
let prefixDescription: string[] | undefined;
|
|
777
|
+
if ('description' in metadata) {
|
|
778
|
+
const descVal = metadata['description'].trim();
|
|
779
|
+
if (descVal) prefixDescription = [descVal];
|
|
780
|
+
delete metadata['description'];
|
|
781
|
+
}
|
|
782
|
+
|
|
765
783
|
const element: C4Element = {
|
|
766
784
|
name: namePart,
|
|
767
785
|
type: elementType,
|
|
768
786
|
shape,
|
|
769
787
|
metadata,
|
|
788
|
+
description: prefixDescription,
|
|
770
789
|
children: [],
|
|
771
790
|
groups: [],
|
|
772
791
|
relationships: [],
|
|
@@ -805,6 +824,15 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
|
|
|
805
824
|
|
|
806
825
|
const key = aliasMap.get(rawKey) ?? rawKey;
|
|
807
826
|
const value = metadataMatch[2].trim();
|
|
827
|
+
|
|
828
|
+
// Extract description into dedicated field
|
|
829
|
+
if (key === 'description') {
|
|
830
|
+
if (!parentEntry.element.description)
|
|
831
|
+
parentEntry.element.description = [];
|
|
832
|
+
parentEntry.element.description.push(value);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
|
|
808
836
|
parentEntry.element.metadata[key] = value;
|
|
809
837
|
continue;
|
|
810
838
|
}
|
|
@@ -821,9 +849,14 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
|
|
|
821
849
|
}
|
|
822
850
|
}
|
|
823
851
|
|
|
824
|
-
// If inside a parent,
|
|
852
|
+
// If inside a parent, try as keyword-based or keywordless description
|
|
825
853
|
const parent = findParentElement(indent, stack);
|
|
826
|
-
if (
|
|
854
|
+
if (parent) {
|
|
855
|
+
const descResult = tryStripDescriptionKeyword(trimmed);
|
|
856
|
+
const descText = descResult.isKeyword ? descResult.text : trimmed;
|
|
857
|
+
if (!parent.element.description) parent.element.description = [];
|
|
858
|
+
parent.element.description.push(descText);
|
|
859
|
+
} else {
|
|
827
860
|
pushError(lineNumber, `Unexpected content: "${trimmed}"`);
|
|
828
861
|
}
|
|
829
862
|
}
|
package/src/c4/renderer.ts
CHANGED
|
@@ -8,6 +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 { renderInlineText } from '../utils/inline-markdown';
|
|
11
|
+
import { preprocessDescriptionLine } from '../utils/description-helpers';
|
|
11
12
|
import type { ParsedC4 } from './types';
|
|
12
13
|
import type { C4LayoutResult, C4LayoutEdge } from './layout';
|
|
13
14
|
import { parseC4 } from './parser';
|
|
@@ -572,7 +573,12 @@ export function renderC4Context(
|
|
|
572
573
|
.attr('dominant-baseline', 'central')
|
|
573
574
|
.attr('fill', palette.textMuted)
|
|
574
575
|
.attr('font-size', DESC_FONT_SIZE);
|
|
575
|
-
renderInlineText(
|
|
576
|
+
renderInlineText(
|
|
577
|
+
textEl,
|
|
578
|
+
preprocessDescriptionLine(line),
|
|
579
|
+
palette,
|
|
580
|
+
DESC_FONT_SIZE
|
|
581
|
+
);
|
|
576
582
|
yPos += DESC_LINE_HEIGHT;
|
|
577
583
|
}
|
|
578
584
|
}
|
|
@@ -1641,7 +1647,12 @@ export function renderC4Containers(
|
|
|
1641
1647
|
.attr('dominant-baseline', 'central')
|
|
1642
1648
|
.attr('fill', palette.textMuted)
|
|
1643
1649
|
.attr('font-size', DESC_FONT_SIZE);
|
|
1644
|
-
renderInlineText(
|
|
1650
|
+
renderInlineText(
|
|
1651
|
+
textEl,
|
|
1652
|
+
preprocessDescriptionLine(line),
|
|
1653
|
+
palette,
|
|
1654
|
+
DESC_FONT_SIZE
|
|
1655
|
+
);
|
|
1645
1656
|
yPos += DESC_LINE_HEIGHT;
|
|
1646
1657
|
}
|
|
1647
1658
|
}
|
|
@@ -1720,7 +1731,12 @@ export function renderC4Containers(
|
|
|
1720
1731
|
.attr('dominant-baseline', 'central')
|
|
1721
1732
|
.attr('fill', palette.textMuted)
|
|
1722
1733
|
.attr('font-size', DESC_FONT_SIZE);
|
|
1723
|
-
renderInlineText(
|
|
1734
|
+
renderInlineText(
|
|
1735
|
+
textEl,
|
|
1736
|
+
preprocessDescriptionLine(line),
|
|
1737
|
+
palette,
|
|
1738
|
+
DESC_FONT_SIZE
|
|
1739
|
+
);
|
|
1724
1740
|
yPos += DESC_LINE_HEIGHT;
|
|
1725
1741
|
}
|
|
1726
1742
|
}
|