@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.
Files changed (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. 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
+ }