@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
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Mindmap SVG Renderer
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import * as d3Selection from 'd3-selection';
|
|
6
|
+
import { FONT_FAMILY } from '../fonts';
|
|
7
|
+
import {
|
|
8
|
+
runInExportContainer,
|
|
9
|
+
extractExportSvg,
|
|
10
|
+
} from '../utils/export-container';
|
|
11
|
+
import type { PaletteColors } from '../palettes';
|
|
12
|
+
import { mix } from '../palettes/color-utils';
|
|
13
|
+
import type { ParsedMindmap } from './types';
|
|
14
|
+
import type { MindmapLayoutResult } from './types';
|
|
15
|
+
import { parseMindmap } from './parser';
|
|
16
|
+
import { layoutMindmap } from './layout';
|
|
17
|
+
import { computeNodeText } from './text-wrap';
|
|
18
|
+
import { renderInlineText } from '../utils/inline-markdown';
|
|
19
|
+
import { preprocessDescriptionLine } from '../utils/description-helpers';
|
|
20
|
+
import { renderLegendD3 } from '../utils/legend-d3';
|
|
21
|
+
import type { LegendConfig, LegendState } from '../utils/legend-types';
|
|
22
|
+
import { LEGEND_HEIGHT, LEGEND_GROUP_GAP } from '../utils/legend-constants';
|
|
23
|
+
import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// Constants
|
|
27
|
+
// ============================================================
|
|
28
|
+
|
|
29
|
+
const DIAGRAM_PADDING = 20;
|
|
30
|
+
const MAX_SCALE = 3;
|
|
31
|
+
const TITLE_HEIGHT = 30;
|
|
32
|
+
const SINGLE_LABEL_HEIGHT = 28;
|
|
33
|
+
const LABEL_LINE_HEIGHT = 18;
|
|
34
|
+
const DESC_LINE_HEIGHT = 14;
|
|
35
|
+
const NODE_RADIUS = 6;
|
|
36
|
+
const ROOT_STROKE_WIDTH = 2.5;
|
|
37
|
+
const NODE_STROKE_WIDTH = 1.5;
|
|
38
|
+
const EDGE_STROKE_WIDTH = 1.5;
|
|
39
|
+
const COLLAPSE_BAR_HEIGHT = 6;
|
|
40
|
+
|
|
41
|
+
function nodeFill(
|
|
42
|
+
palette: PaletteColors,
|
|
43
|
+
isDark: boolean,
|
|
44
|
+
nodeColor?: string
|
|
45
|
+
): string {
|
|
46
|
+
const color = nodeColor ?? palette.primary;
|
|
47
|
+
return mix(color, isDark ? palette.surface : palette.bg, 25);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nodeStroke(palette: PaletteColors, nodeColor?: string): string {
|
|
51
|
+
return nodeColor ?? palette.primary;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Depth color sequence — ROYGBIV-ish from the palette's named colors */
|
|
55
|
+
const DEPTH_COLOR_KEYS = [
|
|
56
|
+
'red',
|
|
57
|
+
'orange',
|
|
58
|
+
'yellow',
|
|
59
|
+
'green',
|
|
60
|
+
'blue',
|
|
61
|
+
'purple',
|
|
62
|
+
'teal',
|
|
63
|
+
'cyan',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function depthColor(depth: number, palette: PaletteColors): string {
|
|
67
|
+
const key = DEPTH_COLOR_KEYS[depth] as
|
|
68
|
+
| keyof typeof palette.colors
|
|
69
|
+
| undefined;
|
|
70
|
+
if (key && key in palette.colors) {
|
|
71
|
+
return palette.colors[key];
|
|
72
|
+
}
|
|
73
|
+
return palette.colors.gray;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================
|
|
77
|
+
// Main renderer
|
|
78
|
+
// ============================================================
|
|
79
|
+
|
|
80
|
+
export function renderMindmap(
|
|
81
|
+
container: HTMLDivElement,
|
|
82
|
+
parsed: ParsedMindmap,
|
|
83
|
+
layout: MindmapLayoutResult,
|
|
84
|
+
palette: PaletteColors,
|
|
85
|
+
isDark: boolean,
|
|
86
|
+
onClickItem?: (lineNumber: number) => void,
|
|
87
|
+
exportDims?: { width?: number; height?: number },
|
|
88
|
+
onToggleNode?: (nodeId: string) => void,
|
|
89
|
+
hideDescriptions?: boolean,
|
|
90
|
+
activeTagGroup?: string | null,
|
|
91
|
+
options?: {
|
|
92
|
+
colorByDepth?: boolean;
|
|
93
|
+
onToggleColorByDepth?: (active: boolean) => void;
|
|
94
|
+
onToggleDescriptions?: (active: boolean) => void;
|
|
95
|
+
controlsExpanded?: boolean;
|
|
96
|
+
onToggleControlsExpand?: () => void;
|
|
97
|
+
}
|
|
98
|
+
): void {
|
|
99
|
+
const isExport = !!exportDims;
|
|
100
|
+
const containerWidth =
|
|
101
|
+
exportDims?.width ?? (container.getBoundingClientRect().width || 800);
|
|
102
|
+
const containerHeight =
|
|
103
|
+
exportDims?.height ?? (container.getBoundingClientRect().height || 600);
|
|
104
|
+
|
|
105
|
+
// Clear existing content
|
|
106
|
+
d3Selection.select(container).selectAll('*').remove();
|
|
107
|
+
|
|
108
|
+
const svg = d3Selection
|
|
109
|
+
.select(container)
|
|
110
|
+
.append('svg')
|
|
111
|
+
.attr('width', containerWidth)
|
|
112
|
+
.attr('height', containerHeight)
|
|
113
|
+
.style('font-family', FONT_FAMILY);
|
|
114
|
+
|
|
115
|
+
// Reserve space for fixed elements (legend, title) in interactive mode
|
|
116
|
+
const hasControls =
|
|
117
|
+
!!options?.onToggleColorByDepth || !!options?.onToggleDescriptions;
|
|
118
|
+
const hasLegend = parsed.tagGroups.length > 0 || hasControls;
|
|
119
|
+
const fixedLegend = !isExport && hasLegend;
|
|
120
|
+
const legendReserve = fixedLegend ? LEGEND_HEIGHT + LEGEND_GROUP_GAP : 0;
|
|
121
|
+
const fixedTitle = !isExport && !!parsed.title;
|
|
122
|
+
const titleReserve = fixedTitle ? TITLE_HEIGHT : 0;
|
|
123
|
+
|
|
124
|
+
// Compute scale to fit diagram in available space
|
|
125
|
+
const availWidth = containerWidth;
|
|
126
|
+
const availHeight =
|
|
127
|
+
containerHeight - DIAGRAM_PADDING * 2 - legendReserve - titleReserve;
|
|
128
|
+
|
|
129
|
+
let scale: number;
|
|
130
|
+
if (isExport) {
|
|
131
|
+
scale = 1;
|
|
132
|
+
} else {
|
|
133
|
+
const scaleX = layout.width > 0 ? availWidth / layout.width : 1;
|
|
134
|
+
const scaleY = layout.height > 0 ? availHeight / layout.height : 1;
|
|
135
|
+
scale = Math.min(scaleX, scaleY, MAX_SCALE);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const scaledWidth = layout.width * scale;
|
|
139
|
+
const scaledHeight = layout.height * scale;
|
|
140
|
+
const offsetX = (availWidth - scaledWidth) / 2;
|
|
141
|
+
const offsetY =
|
|
142
|
+
DIAGRAM_PADDING +
|
|
143
|
+
legendReserve +
|
|
144
|
+
titleReserve +
|
|
145
|
+
(availHeight - scaledHeight) / 2;
|
|
146
|
+
|
|
147
|
+
// Main group with scale transform (created early so title can reference it in export mode)
|
|
148
|
+
const mainG = svg
|
|
149
|
+
.append('g')
|
|
150
|
+
.attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
|
|
151
|
+
|
|
152
|
+
// Title — fixed at top in app mode (above legend), inside scaled group in export
|
|
153
|
+
if (parsed.title) {
|
|
154
|
+
const titleParent = fixedTitle ? svg : mainG;
|
|
155
|
+
const titleX = fixedTitle ? containerWidth / 2 : layout.width / 2;
|
|
156
|
+
const titleY = fixedTitle
|
|
157
|
+
? DIAGRAM_PADDING + TITLE_FONT_SIZE
|
|
158
|
+
: TITLE_FONT_SIZE;
|
|
159
|
+
const titleText = titleParent
|
|
160
|
+
.append('text')
|
|
161
|
+
.attr('x', titleX)
|
|
162
|
+
.attr('y', titleY)
|
|
163
|
+
.attr('text-anchor', 'middle')
|
|
164
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
165
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
166
|
+
.attr('fill', palette.text)
|
|
167
|
+
.attr('class', 'chart-title')
|
|
168
|
+
.text(parsed.title);
|
|
169
|
+
|
|
170
|
+
if (parsed.titleLineNumber) {
|
|
171
|
+
titleText.attr('data-line-number', parsed.titleLineNumber);
|
|
172
|
+
}
|
|
173
|
+
if (onClickItem && parsed.titleLineNumber) {
|
|
174
|
+
titleText
|
|
175
|
+
.style('cursor', 'pointer')
|
|
176
|
+
.on('click', () => onClickItem(parsed.titleLineNumber!));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Legend — fixed below title, outside scaled group, in interactive mode
|
|
181
|
+
if (fixedLegend) {
|
|
182
|
+
const legendG = svg
|
|
183
|
+
.append('g')
|
|
184
|
+
.attr('class', 'mindmap-legend-fixed')
|
|
185
|
+
.attr('transform', `translate(0, ${DIAGRAM_PADDING + titleReserve})`);
|
|
186
|
+
|
|
187
|
+
// Collect used tag values from all nodes to filter legend entries
|
|
188
|
+
const usedValues = new Map<string, Set<string>>(); // groupName → set of used values
|
|
189
|
+
for (const node of layout.nodes) {
|
|
190
|
+
for (const tg of parsed.tagGroups) {
|
|
191
|
+
const key = tg.name.toLowerCase();
|
|
192
|
+
const val = node.metadata[key];
|
|
193
|
+
if (val) {
|
|
194
|
+
if (!usedValues.has(key)) usedValues.set(key, new Set());
|
|
195
|
+
usedValues.get(key)!.add(val.toLowerCase());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Build controls toggles
|
|
201
|
+
const toggles: import('../utils/legend-types').ControlsGroupToggle[] = [];
|
|
202
|
+
if (options?.onToggleDescriptions) {
|
|
203
|
+
toggles.push({
|
|
204
|
+
id: 'descriptions',
|
|
205
|
+
type: 'toggle' as const,
|
|
206
|
+
label: 'Descriptions',
|
|
207
|
+
active: !hideDescriptions,
|
|
208
|
+
onToggle: (active) => options.onToggleDescriptions!(active),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (options?.onToggleColorByDepth) {
|
|
212
|
+
toggles.push({
|
|
213
|
+
id: 'depth-colors',
|
|
214
|
+
type: 'toggle' as const,
|
|
215
|
+
label: 'Depth Colors',
|
|
216
|
+
active: options.colorByDepth ?? false,
|
|
217
|
+
onToggle: options.onToggleColorByDepth,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const controlsToggles: LegendConfig['controlsGroup'] =
|
|
221
|
+
toggles.length > 0 ? { toggles } : undefined;
|
|
222
|
+
|
|
223
|
+
const legendConfig: LegendConfig = {
|
|
224
|
+
groups: parsed.tagGroups.map((tg) => {
|
|
225
|
+
const used = usedValues.get(tg.name.toLowerCase());
|
|
226
|
+
return {
|
|
227
|
+
name: tg.name,
|
|
228
|
+
alias: tg.alias,
|
|
229
|
+
entries: tg.entries
|
|
230
|
+
.filter((e) => used?.has(e.value.toLowerCase()))
|
|
231
|
+
.map((e) => ({ value: e.value, color: e.color })),
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
235
|
+
mode: 'fixed',
|
|
236
|
+
controlsGroup: controlsToggles,
|
|
237
|
+
};
|
|
238
|
+
const legendState: LegendState = {
|
|
239
|
+
activeGroup: options?.colorByDepth
|
|
240
|
+
? null
|
|
241
|
+
: activeTagGroup !== undefined
|
|
242
|
+
? activeTagGroup
|
|
243
|
+
: (parsed.options['active-tag'] ?? null),
|
|
244
|
+
hiddenAttributes: new Set(),
|
|
245
|
+
controlsExpanded: options?.controlsExpanded,
|
|
246
|
+
};
|
|
247
|
+
const legendPalette = {
|
|
248
|
+
text: palette.text,
|
|
249
|
+
textMuted: palette.textMuted,
|
|
250
|
+
bg: palette.bg,
|
|
251
|
+
surface: palette.surface,
|
|
252
|
+
primary: palette.primary,
|
|
253
|
+
};
|
|
254
|
+
const legendCallbacks: import('../utils/legend-types').LegendCallbacks = {
|
|
255
|
+
onControlsExpand: options?.onToggleControlsExpand,
|
|
256
|
+
onControlsToggle: (id, active) => {
|
|
257
|
+
if (id === 'depth-colors' && options?.onToggleColorByDepth) {
|
|
258
|
+
options.onToggleColorByDepth(active);
|
|
259
|
+
}
|
|
260
|
+
if (id === 'descriptions' && options?.onToggleDescriptions) {
|
|
261
|
+
options.onToggleDescriptions(active);
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
renderLegendD3(
|
|
266
|
+
legendG,
|
|
267
|
+
legendConfig,
|
|
268
|
+
legendState,
|
|
269
|
+
legendPalette,
|
|
270
|
+
isDark,
|
|
271
|
+
legendCallbacks,
|
|
272
|
+
containerWidth
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Render edges (background layer)
|
|
277
|
+
for (const edge of layout.edges) {
|
|
278
|
+
mainG
|
|
279
|
+
.append('path')
|
|
280
|
+
.attr('class', 'mindmap-edge')
|
|
281
|
+
.attr('d', edge.path)
|
|
282
|
+
.attr('fill', 'none')
|
|
283
|
+
.attr('stroke', palette.textMuted)
|
|
284
|
+
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
285
|
+
.attr('stroke-opacity', 0.5);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Render nodes (foreground layer)
|
|
289
|
+
for (const node of layout.nodes) {
|
|
290
|
+
const isRoot = node.radius === 0 && layout.nodes.indexOf(node) === 0;
|
|
291
|
+
const strokeW = isRoot ? ROOT_STROKE_WIDTH : NODE_STROKE_WIDTH;
|
|
292
|
+
const effectiveColor = options?.colorByDepth
|
|
293
|
+
? depthColor(node.depth, palette)
|
|
294
|
+
: node.color;
|
|
295
|
+
const fill = nodeFill(palette, isDark, effectiveColor);
|
|
296
|
+
const stroke = nodeStroke(palette, effectiveColor);
|
|
297
|
+
|
|
298
|
+
const nodeG = mainG
|
|
299
|
+
.append('g')
|
|
300
|
+
.attr('class', 'mindmap-node')
|
|
301
|
+
.attr('data-line-number', node.lineNumber);
|
|
302
|
+
|
|
303
|
+
// Expose active tag group value for legend-entry hover dimming
|
|
304
|
+
if (activeTagGroup) {
|
|
305
|
+
const tagKey = activeTagGroup.toLowerCase();
|
|
306
|
+
const metaValue = node.metadata[tagKey];
|
|
307
|
+
if (metaValue) {
|
|
308
|
+
nodeG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Add collapse toggle attributes for nodes with children
|
|
313
|
+
if (node.hasChildren) {
|
|
314
|
+
nodeG
|
|
315
|
+
.attr('data-node-toggle', node.id)
|
|
316
|
+
.attr('tabindex', '0')
|
|
317
|
+
.attr('role', 'button')
|
|
318
|
+
.attr(
|
|
319
|
+
'aria-expanded',
|
|
320
|
+
node.hiddenCount != null && node.hiddenCount > 0 ? 'false' : 'true'
|
|
321
|
+
)
|
|
322
|
+
.attr('aria-label', node.label);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Node rectangle
|
|
326
|
+
nodeG
|
|
327
|
+
.append('rect')
|
|
328
|
+
.attr('x', node.x)
|
|
329
|
+
.attr('y', node.y)
|
|
330
|
+
.attr('width', node.width)
|
|
331
|
+
.attr('height', node.height)
|
|
332
|
+
.attr('rx', NODE_RADIUS)
|
|
333
|
+
.attr('ry', NODE_RADIUS)
|
|
334
|
+
.attr('fill', fill)
|
|
335
|
+
.attr('stroke', stroke)
|
|
336
|
+
.attr('stroke-width', strokeW);
|
|
337
|
+
|
|
338
|
+
// Determine if description is visible (needed for label centering)
|
|
339
|
+
const collapsed = node.hiddenCount != null && node.hiddenCount > 0;
|
|
340
|
+
const showDesc = !hideDescriptions && !!node.description && !collapsed;
|
|
341
|
+
|
|
342
|
+
// Compute wrapped text layout (same logic as layout.ts for sizing agreement)
|
|
343
|
+
const textLayout = computeNodeText(
|
|
344
|
+
node.label,
|
|
345
|
+
node.description,
|
|
346
|
+
node.depth,
|
|
347
|
+
node.width,
|
|
348
|
+
hideDescriptions || collapsed
|
|
349
|
+
);
|
|
350
|
+
const {
|
|
351
|
+
labelLines,
|
|
352
|
+
labelFontSize: fontSize,
|
|
353
|
+
descLines,
|
|
354
|
+
descFontSize,
|
|
355
|
+
} = textLayout;
|
|
356
|
+
|
|
357
|
+
// Label zone height
|
|
358
|
+
const labelLineCount = labelLines.length;
|
|
359
|
+
const labelZoneH =
|
|
360
|
+
labelLineCount <= 1
|
|
361
|
+
? SINGLE_LABEL_HEIGHT
|
|
362
|
+
: LABEL_LINE_HEIGHT * labelLineCount;
|
|
363
|
+
const labelZoneHeight = showDesc ? labelZoneH : node.height;
|
|
364
|
+
|
|
365
|
+
// Label text — vertically centered in the label zone
|
|
366
|
+
const centerX = node.x + node.width / 2;
|
|
367
|
+
if (labelLineCount <= 1) {
|
|
368
|
+
// Single line — simple centering
|
|
369
|
+
const labelY = node.y + labelZoneHeight / 2 + fontSize * 0.35;
|
|
370
|
+
nodeG
|
|
371
|
+
.append('text')
|
|
372
|
+
.attr('x', centerX)
|
|
373
|
+
.attr('y', labelY)
|
|
374
|
+
.attr('text-anchor', 'middle')
|
|
375
|
+
.attr('font-size', fontSize)
|
|
376
|
+
.attr('font-weight', isRoot ? 'bold' : 'normal')
|
|
377
|
+
.attr('fill', palette.text)
|
|
378
|
+
.text(labelLines[0]);
|
|
379
|
+
} else {
|
|
380
|
+
// Multi-line — use tspan elements
|
|
381
|
+
// Visual text block spans from first baseline to last baseline:
|
|
382
|
+
// blockH = (lineCount - 1) * lineHeight
|
|
383
|
+
// Center that block in the zone, then offset each baseline by fontSize * 0.35
|
|
384
|
+
const blockH = LABEL_LINE_HEIGHT * (labelLineCount - 1);
|
|
385
|
+
const firstBaselineY =
|
|
386
|
+
node.y + labelZoneHeight / 2 - blockH / 2 + fontSize * 0.35;
|
|
387
|
+
const textEl = nodeG
|
|
388
|
+
.append('text')
|
|
389
|
+
.attr('x', centerX)
|
|
390
|
+
.attr('text-anchor', 'middle')
|
|
391
|
+
.attr('font-size', fontSize)
|
|
392
|
+
.attr('font-weight', isRoot ? 'bold' : 'normal')
|
|
393
|
+
.attr('fill', palette.text);
|
|
394
|
+
|
|
395
|
+
for (let i = 0; i < labelLines.length; i++) {
|
|
396
|
+
textEl
|
|
397
|
+
.append('tspan')
|
|
398
|
+
.attr('x', centerX)
|
|
399
|
+
.attr('y', firstBaselineY + i * LABEL_LINE_HEIGHT)
|
|
400
|
+
.text(labelLines[i]);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Hover tooltip for truncated/wrapped labels — on the <g>, not on <text>
|
|
405
|
+
if (labelLines.length > 1 || labelLines[0] !== node.label) {
|
|
406
|
+
nodeG.append('title').text(node.label);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Description — separator line + muted text below label
|
|
410
|
+
if (showDesc && descLines.length > 0) {
|
|
411
|
+
const separatorY = node.y + labelZoneH;
|
|
412
|
+
|
|
413
|
+
// Separator line
|
|
414
|
+
nodeG
|
|
415
|
+
.append('line')
|
|
416
|
+
.attr('x1', node.x)
|
|
417
|
+
.attr('y1', separatorY)
|
|
418
|
+
.attr('x2', node.x + node.width)
|
|
419
|
+
.attr('y2', separatorY)
|
|
420
|
+
.attr('stroke', stroke)
|
|
421
|
+
.attr('stroke-opacity', 0.3)
|
|
422
|
+
.attr('stroke-width', 1);
|
|
423
|
+
|
|
424
|
+
// Description text (with inline markdown + preprocessing)
|
|
425
|
+
if (descLines.length <= 1) {
|
|
426
|
+
const descY = separatorY + 4 + descFontSize;
|
|
427
|
+
const processed = preprocessDescriptionLine(descLines[0]);
|
|
428
|
+
const textEl = nodeG
|
|
429
|
+
.append('text')
|
|
430
|
+
.attr('x', centerX)
|
|
431
|
+
.attr('y', descY)
|
|
432
|
+
.attr('text-anchor', 'middle')
|
|
433
|
+
.attr('font-size', descFontSize)
|
|
434
|
+
.attr('fill', palette.textMuted);
|
|
435
|
+
renderInlineText(textEl, processed, palette, descFontSize);
|
|
436
|
+
} else {
|
|
437
|
+
const descStartY = separatorY + 4 + descFontSize;
|
|
438
|
+
for (let i = 0; i < descLines.length; i++) {
|
|
439
|
+
const processed = preprocessDescriptionLine(descLines[i]);
|
|
440
|
+
const textEl = nodeG
|
|
441
|
+
.append('text')
|
|
442
|
+
.attr('x', centerX)
|
|
443
|
+
.attr('y', descStartY + i * DESC_LINE_HEIGHT)
|
|
444
|
+
.attr('text-anchor', 'middle')
|
|
445
|
+
.attr('font-size', descFontSize)
|
|
446
|
+
.attr('fill', palette.textMuted);
|
|
447
|
+
renderInlineText(textEl, processed, palette, descFontSize);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Collapse drill-bar (interactive mode only)
|
|
453
|
+
if (!isExport && node.hiddenCount != null && node.hiddenCount > 0) {
|
|
454
|
+
// Clip path for rounded bottom
|
|
455
|
+
const clipId = `collapse-clip-${node.id}`;
|
|
456
|
+
const defs = mainG.append('defs');
|
|
457
|
+
defs
|
|
458
|
+
.append('clipPath')
|
|
459
|
+
.attr('id', clipId)
|
|
460
|
+
.append('rect')
|
|
461
|
+
.attr('x', node.x)
|
|
462
|
+
.attr('y', node.y)
|
|
463
|
+
.attr('width', node.width)
|
|
464
|
+
.attr('height', node.height)
|
|
465
|
+
.attr('rx', NODE_RADIUS)
|
|
466
|
+
.attr('ry', NODE_RADIUS);
|
|
467
|
+
|
|
468
|
+
nodeG
|
|
469
|
+
.append('rect')
|
|
470
|
+
.attr('class', 'collapse-bar')
|
|
471
|
+
.attr('x', node.x)
|
|
472
|
+
.attr('y', node.y + node.height - COLLAPSE_BAR_HEIGHT)
|
|
473
|
+
.attr('width', node.width)
|
|
474
|
+
.attr('height', COLLAPSE_BAR_HEIGHT)
|
|
475
|
+
.attr('fill', stroke)
|
|
476
|
+
.attr('clip-path', `url(#${clipId})`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Click handler
|
|
480
|
+
if (onClickItem) {
|
|
481
|
+
nodeG.style('cursor', 'pointer').on('click', (event: Event) => {
|
|
482
|
+
// If this node has a toggle and the toggle callback exists,
|
|
483
|
+
// use toggle behavior instead of navigation
|
|
484
|
+
if (node.hasChildren && onToggleNode) {
|
|
485
|
+
event.stopPropagation();
|
|
486
|
+
onToggleNode(node.id);
|
|
487
|
+
} else {
|
|
488
|
+
onClickItem(node.lineNumber);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Hover opacity
|
|
494
|
+
if (!isExport) {
|
|
495
|
+
nodeG
|
|
496
|
+
.on('mouseenter', function () {
|
|
497
|
+
d3Selection.select(this).attr('opacity', 0.7);
|
|
498
|
+
})
|
|
499
|
+
.on('mouseleave', function () {
|
|
500
|
+
d3Selection.select(this).attr('opacity', 1);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ============================================================
|
|
507
|
+
// Export convenience function
|
|
508
|
+
// ============================================================
|
|
509
|
+
|
|
510
|
+
export function renderMindmapForExport(
|
|
511
|
+
content: string,
|
|
512
|
+
theme: 'light' | 'dark' | 'transparent',
|
|
513
|
+
palette: PaletteColors
|
|
514
|
+
): string {
|
|
515
|
+
const parsed = parseMindmap(content, palette);
|
|
516
|
+
if (parsed.error) return '';
|
|
517
|
+
|
|
518
|
+
const isDark = theme === 'dark';
|
|
519
|
+
const hideDescriptions = parsed.options['hide-descriptions'] === 'true';
|
|
520
|
+
|
|
521
|
+
const layout = layoutMindmap(parsed, palette, {
|
|
522
|
+
interactive: false,
|
|
523
|
+
hideDescriptions,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const titleOffset = parsed.title ? TITLE_HEIGHT : 0;
|
|
527
|
+
const exportWidth = layout.width + DIAGRAM_PADDING * 2;
|
|
528
|
+
const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
|
|
529
|
+
|
|
530
|
+
return runInExportContainer(exportWidth, exportHeight, (container) => {
|
|
531
|
+
renderMindmap(
|
|
532
|
+
container,
|
|
533
|
+
parsed,
|
|
534
|
+
layout,
|
|
535
|
+
palette,
|
|
536
|
+
isDark,
|
|
537
|
+
undefined,
|
|
538
|
+
{ width: exportWidth, height: exportHeight },
|
|
539
|
+
undefined,
|
|
540
|
+
hideDescriptions
|
|
541
|
+
);
|
|
542
|
+
return extractExportSvg(container, theme);
|
|
543
|
+
});
|
|
544
|
+
}
|