@diagrammo/dgmo 0.4.2 → 0.4.4

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 (60) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +197 -154
  9. package/dist/index.cjs +8647 -3447
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +503 -58
  12. package/dist/index.d.ts +503 -58
  13. package/dist/index.js +8379 -3200
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +578 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1553 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/collapse.ts +187 -0
  56. package/src/sitemap/layout.ts +738 -0
  57. package/src/sitemap/parser.ts +489 -0
  58. package/src/sitemap/renderer.ts +774 -0
  59. package/src/sitemap/types.ts +42 -0
  60. package/src/utils/tag-groups.ts +119 -0
@@ -0,0 +1,774 @@
1
+ // ============================================================
2
+ // Sitemap Diagram SVG Renderer
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import * as d3Shape from 'd3-shape';
7
+ import { FONT_FAMILY } from '../fonts';
8
+ import type { PaletteColors } from '../palettes';
9
+ import { mix } from '../palettes/color-utils';
10
+ import type { ParsedSitemap } from './types';
11
+ import type {
12
+ SitemapLayoutResult,
13
+ SitemapLayoutNode,
14
+ SitemapLayoutEdge,
15
+ SitemapContainerBounds,
16
+ SitemapLegendGroup,
17
+ } from './layout';
18
+
19
+ // ============================================================
20
+ // Constants
21
+ // ============================================================
22
+
23
+ const DIAGRAM_PADDING = 20;
24
+ const MAX_SCALE = 3;
25
+ const TITLE_HEIGHT = 30;
26
+ const TITLE_FONT_SIZE = 18;
27
+ const LABEL_FONT_SIZE = 13;
28
+ const META_FONT_SIZE = 11;
29
+ const META_LINE_HEIGHT = 16;
30
+ const HEADER_HEIGHT = 28;
31
+ const SEPARATOR_GAP = 6;
32
+ const EDGE_STROKE_WIDTH = 1.5;
33
+ const NODE_STROKE_WIDTH = 1.5;
34
+ const CARD_RADIUS = 6;
35
+ const CONTAINER_RADIUS = 8;
36
+ const CONTAINER_LABEL_FONT_SIZE = 13;
37
+ const CONTAINER_META_FONT_SIZE = 11;
38
+ const CONTAINER_META_LINE_HEIGHT = 16;
39
+ const CONTAINER_HEADER_HEIGHT = 28;
40
+ const ARROWHEAD_W = 10;
41
+ const ARROWHEAD_H = 7;
42
+ const EDGE_LABEL_FONT_SIZE = 11;
43
+
44
+ // Collapsed-node accent bar
45
+ const COLLAPSE_BAR_HEIGHT = 6;
46
+
47
+ // Legend
48
+ const LEGEND_HEIGHT = 28;
49
+ const LEGEND_FIXED_GAP = 8;
50
+ const LEGEND_PILL_PAD = 16;
51
+ const LEGEND_PILL_FONT_SIZE = 11;
52
+ const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
53
+ const LEGEND_CAPSULE_PAD = 4;
54
+ const LEGEND_DOT_R = 4;
55
+ const LEGEND_ENTRY_FONT_SIZE = 10;
56
+ const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
57
+ const LEGEND_ENTRY_DOT_GAP = 4;
58
+ const LEGEND_ENTRY_TRAIL = 8;
59
+ const LEGEND_GROUP_GAP = 12;
60
+ const LEGEND_EYE_SIZE = 14;
61
+ const LEGEND_EYE_GAP = 6;
62
+
63
+ // ============================================================
64
+ // Color helpers
65
+ // ============================================================
66
+
67
+ function nodeFill(palette: PaletteColors, isDark: boolean, nodeColor?: string): string {
68
+ const color = nodeColor ?? palette.primary;
69
+ return mix(color, isDark ? palette.surface : palette.bg, 25);
70
+ }
71
+
72
+ function nodeStroke(_palette: PaletteColors, nodeColor?: string): string {
73
+ return nodeColor ?? _palette.primary;
74
+ }
75
+
76
+ function containerFill(palette: PaletteColors, isDark: boolean, nodeColor?: string): string {
77
+ if (nodeColor) {
78
+ return mix(nodeColor, isDark ? palette.surface : palette.bg, 10);
79
+ }
80
+ return mix(palette.surface, palette.bg, 40);
81
+ }
82
+
83
+ function containerStroke(palette: PaletteColors, nodeColor?: string): string {
84
+ return nodeColor ?? palette.textMuted;
85
+ }
86
+
87
+ // ============================================================
88
+ // Curve generator
89
+ // ============================================================
90
+
91
+ const lineGenerator = d3Shape.line<{ x: number; y: number }>()
92
+ .x((d) => d.x)
93
+ .y((d) => d.y)
94
+ .curve(d3Shape.curveBasis);
95
+
96
+ // ============================================================
97
+ // Main Renderer
98
+ // ============================================================
99
+
100
+ type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
101
+
102
+ export function renderSitemap(
103
+ container: HTMLDivElement,
104
+ parsed: ParsedSitemap,
105
+ layout: SitemapLayoutResult,
106
+ palette: PaletteColors,
107
+ isDark: boolean,
108
+ onClickItem?: (lineNumber: number) => void,
109
+ exportDims?: { width?: number; height?: number },
110
+ activeTagGroup?: string | null,
111
+ hiddenAttributes?: Set<string>,
112
+ ): void {
113
+ // Clear existing content
114
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
115
+
116
+ const width = exportDims?.width ?? container.clientWidth;
117
+ const height = exportDims?.height ?? container.clientHeight;
118
+ if (width <= 0 || height <= 0) return;
119
+
120
+ const hasLegend = layout.legend.length > 0;
121
+
122
+ // In app mode (not export), render the title and legend at fixed size
123
+ // outside the scaled group so they stay legible on large sitemaps.
124
+ // Layout order: Title → Legend → Diagram content.
125
+ const layoutLegendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP; // 40px — what layout added
126
+ const fixedLegend = !exportDims && hasLegend;
127
+ const fixedTitle = fixedLegend && !!parsed.title;
128
+ const fixedTitleH = fixedTitle ? TITLE_HEIGHT : 0;
129
+ const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
130
+ // Total fixed pixel space above the scaled content
131
+ const fixedReserve = fixedTitleH + legendReserveH;
132
+ // Title inside scaled group only when legend is NOT fixed
133
+ const titleOffset = !fixedTitle && parsed.title ? TITLE_HEIGHT : 0;
134
+
135
+ // Compute scale to fit diagram in viewport
136
+ const diagramW = layout.width;
137
+ let diagramH = layout.height + titleOffset;
138
+ if (fixedLegend) {
139
+ // Remove the legend space from diagram height — legend is rendered separately
140
+ diagramH -= layoutLegendShift;
141
+ }
142
+ const availH = height - DIAGRAM_PADDING * 2 - fixedReserve;
143
+ const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
144
+ const scaleY = availH / diagramH;
145
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
146
+
147
+ const scaledW = diagramW * scale;
148
+ const offsetX = (width - scaledW) / 2;
149
+ const offsetY = DIAGRAM_PADDING + fixedReserve;
150
+
151
+ // Create SVG
152
+ const svg = d3Selection
153
+ .select(container)
154
+ .append('svg')
155
+ .attr('width', width)
156
+ .attr('height', height)
157
+ .style('font-family', FONT_FAMILY);
158
+
159
+ // Defs: arrowhead markers
160
+ const defs = svg.append('defs');
161
+
162
+ // Default arrowhead
163
+ defs
164
+ .append('marker')
165
+ .attr('id', 'sm-arrow')
166
+ .attr('viewBox', `0 0 ${ARROWHEAD_W} ${ARROWHEAD_H}`)
167
+ .attr('refX', ARROWHEAD_W)
168
+ .attr('refY', ARROWHEAD_H / 2)
169
+ .attr('markerWidth', ARROWHEAD_W)
170
+ .attr('markerHeight', ARROWHEAD_H)
171
+ .attr('orient', 'auto')
172
+ .append('polygon')
173
+ .attr('points', `0,0 ${ARROWHEAD_W},${ARROWHEAD_H / 2} 0,${ARROWHEAD_H}`)
174
+ .attr('fill', palette.textMuted);
175
+
176
+ // Colored arrowheads
177
+ const edgeColors = new Set<string>();
178
+ for (const edge of layout.edges) {
179
+ if (edge.color) edgeColors.add(edge.color);
180
+ }
181
+ for (const color of edgeColors) {
182
+ const id = `sm-arrow-${color.replace('#', '')}`;
183
+ defs
184
+ .append('marker')
185
+ .attr('id', id)
186
+ .attr('viewBox', `0 0 ${ARROWHEAD_W} ${ARROWHEAD_H}`)
187
+ .attr('refX', ARROWHEAD_W)
188
+ .attr('refY', ARROWHEAD_H / 2)
189
+ .attr('markerWidth', ARROWHEAD_W)
190
+ .attr('markerHeight', ARROWHEAD_H)
191
+ .attr('orient', 'auto')
192
+ .append('polygon')
193
+ .attr('points', `0,0 ${ARROWHEAD_W},${ARROWHEAD_H / 2} 0,${ARROWHEAD_H}`)
194
+ .attr('fill', color);
195
+ }
196
+
197
+ // Main content group with scale/translate
198
+ const mainG = svg
199
+ .append('g')
200
+ .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
201
+
202
+ // Title (scaled, only when legend is NOT fixed)
203
+ if (!fixedTitle && parsed.title) {
204
+ const titleEl = mainG
205
+ .append('text')
206
+ .attr('x', diagramW / 2)
207
+ .attr('y', TITLE_FONT_SIZE)
208
+ .attr('text-anchor', 'middle')
209
+ .attr('fill', palette.text)
210
+ .attr('font-size', TITLE_FONT_SIZE)
211
+ .attr('font-weight', 'bold')
212
+ .attr('class', 'sitemap-title chart-title');
213
+
214
+ if (parsed.titleLineNumber) {
215
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
216
+ if (onClickItem) {
217
+ titleEl
218
+ .style('cursor', 'pointer')
219
+ .on('click', () => onClickItem(parsed.titleLineNumber!));
220
+ }
221
+ }
222
+
223
+ titleEl.text(parsed.title);
224
+ }
225
+
226
+ // Content group (offset by title; pull up by legendShift when legend is fixed)
227
+ const contentShift = fixedLegend ? -layoutLegendShift : 0;
228
+ const contentG = mainG
229
+ .append('g')
230
+ .attr('transform', `translate(0, ${titleOffset + contentShift})`);
231
+
232
+ // Build display name map + tag color lookup from tag groups
233
+ const displayNames = new Map<string, string>();
234
+ // tagColors: "groupkey:valueLower" → hex color
235
+ const tagColors = new Map<string, string>();
236
+ for (const group of parsed.tagGroups) {
237
+ displayNames.set(group.name.toLowerCase(), group.name);
238
+ for (const entry of group.entries) {
239
+ tagColors.set(`${group.name.toLowerCase()}:${entry.value.toLowerCase()}`, entry.color);
240
+ }
241
+ }
242
+
243
+ // --- Render containers (bottom layer) ---
244
+ for (const c of layout.containers) {
245
+ const cG = contentG
246
+ .append('g')
247
+ .attr('transform', `translate(${c.x}, ${c.y})`)
248
+ .attr('class', 'sitemap-container')
249
+ .attr('data-line-number', String(c.lineNumber)) as GSelection;
250
+
251
+ if (c.hasChildren) {
252
+ cG.attr('data-node-toggle', c.nodeId)
253
+ .attr('tabindex', '0')
254
+ .attr('role', 'button')
255
+ .attr('aria-expanded', String(!c.hiddenCount))
256
+ .attr('aria-label', c.label);
257
+ }
258
+
259
+ if (onClickItem) {
260
+ cG.style('cursor', 'pointer').on('click', () => onClickItem(c.lineNumber));
261
+ }
262
+
263
+ // Tag metadata for legend hover dimming
264
+ if (activeTagGroup) {
265
+ const tagKey = activeTagGroup.toLowerCase();
266
+ const tagVal = c.tagMetadata[tagKey];
267
+ if (tagVal) cG.attr(`data-tag-${tagKey}`, tagVal.toLowerCase());
268
+ }
269
+
270
+ const fill = containerFill(palette, isDark, c.color);
271
+ const stroke = containerStroke(palette, c.color);
272
+
273
+ cG.append('rect')
274
+ .attr('x', 0)
275
+ .attr('y', 0)
276
+ .attr('width', c.width)
277
+ .attr('height', c.height)
278
+ .attr('rx', CONTAINER_RADIUS)
279
+ .attr('fill', fill)
280
+ .attr('stroke', stroke)
281
+ .attr('stroke-opacity', 0.35)
282
+ .attr('stroke-width', NODE_STROKE_WIDTH);
283
+
284
+ // Container label
285
+ cG.append('text')
286
+ .attr('x', c.width / 2)
287
+ .attr('y', CONTAINER_HEADER_HEIGHT / 2 + CONTAINER_LABEL_FONT_SIZE / 2 - 2)
288
+ .attr('text-anchor', 'middle')
289
+ .attr('fill', palette.text)
290
+ .attr('font-size', CONTAINER_LABEL_FONT_SIZE)
291
+ .attr('font-weight', 'bold')
292
+ .text(c.label);
293
+
294
+ // Container metadata
295
+ const metaEntries = Object.entries(c.metadata);
296
+ if (metaEntries.length > 0) {
297
+ const metaDisplayKeys = metaEntries.map(([k]) => displayNames.get(k) ?? k);
298
+ const maxKeyLen = Math.max(...metaDisplayKeys.map((k) => k.length));
299
+ const valueX = 10 + (maxKeyLen + 2) * (CONTAINER_META_FONT_SIZE * 0.6);
300
+ const metaStartY = CONTAINER_HEADER_HEIGHT + CONTAINER_META_FONT_SIZE - 2;
301
+
302
+ for (let i = 0; i < metaEntries.length; i++) {
303
+ const [key, value] = metaEntries[i];
304
+ const displayKey = metaDisplayKeys[i];
305
+ const rowY = metaStartY + i * CONTAINER_META_LINE_HEIGHT;
306
+ const valColor = tagColors.get(`${key}:${value.toLowerCase()}`) ?? palette.text;
307
+
308
+ cG.append('text')
309
+ .attr('x', 10)
310
+ .attr('y', rowY)
311
+ .attr('fill', palette.textMuted)
312
+ .attr('font-size', CONTAINER_META_FONT_SIZE)
313
+ .text(`${displayKey}: `);
314
+
315
+ cG.append('text')
316
+ .attr('x', valueX)
317
+ .attr('y', rowY)
318
+ .attr('fill', valColor)
319
+ .attr('font-size', CONTAINER_META_FONT_SIZE)
320
+ .text(value);
321
+ }
322
+ }
323
+
324
+ // Collapsed accent bar
325
+ if (!exportDims && c.hiddenCount && c.hiddenCount > 0) {
326
+ const clipId = `clip-${c.nodeId}`;
327
+ cG.append('clipPath').attr('id', clipId)
328
+ .append('rect')
329
+ .attr('width', c.width).attr('height', c.height)
330
+ .attr('rx', CONTAINER_RADIUS);
331
+ cG.append('rect')
332
+ .attr('y', c.height - COLLAPSE_BAR_HEIGHT)
333
+ .attr('width', c.width)
334
+ .attr('height', COLLAPSE_BAR_HEIGHT)
335
+ .attr('fill', c.color ?? palette.primary)
336
+ .attr('opacity', 0.5)
337
+ .attr('clip-path', `url(#${clipId})`);
338
+
339
+ cG.append('text')
340
+ .attr('x', c.width / 2)
341
+ .attr('y', c.height - COLLAPSE_BAR_HEIGHT - 6)
342
+ .attr('text-anchor', 'middle')
343
+ .attr('fill', palette.textMuted)
344
+ .attr('font-size', META_FONT_SIZE)
345
+ .text(`+${c.hiddenCount}`);
346
+ }
347
+ }
348
+
349
+ // --- Render edges (middle layer) ---
350
+ for (const edge of layout.edges) {
351
+ if (edge.points.length < 2) continue;
352
+
353
+ const edgeG = contentG
354
+ .append('g')
355
+ .attr('class', 'sitemap-edge-group')
356
+ .attr('data-line-number', String(edge.lineNumber));
357
+
358
+ const edgeColor = edge.color ?? palette.textMuted;
359
+ const markerId = edge.color
360
+ ? `sm-arrow-${edge.color.replace('#', '')}`
361
+ : 'sm-arrow';
362
+
363
+ const pathD = lineGenerator(edge.points);
364
+ if (pathD) {
365
+ edgeG
366
+ .append('path')
367
+ .attr('d', pathD)
368
+ .attr('fill', 'none')
369
+ .attr('stroke', edgeColor)
370
+ .attr('stroke-width', EDGE_STROKE_WIDTH)
371
+ .attr('marker-end', `url(#${markerId})`)
372
+ .attr('class', 'sitemap-edge');
373
+ }
374
+
375
+ // Edge label with background badge
376
+ if (edge.label && edge.points.length >= 2) {
377
+ const mid = edge.points[Math.floor(edge.points.length / 2)];
378
+ const labelW = edge.label.length * EDGE_LABEL_FONT_SIZE * 0.6 + 10;
379
+ const labelH = EDGE_LABEL_FONT_SIZE + 6;
380
+
381
+ edgeG
382
+ .append('rect')
383
+ .attr('x', mid.x - labelW / 2)
384
+ .attr('y', mid.y - labelH / 2 - 1)
385
+ .attr('width', labelW)
386
+ .attr('height', labelH)
387
+ .attr('rx', 3)
388
+ .attr('fill', palette.bg)
389
+ .attr('opacity', 0.85)
390
+ .attr('class', 'sitemap-edge-label-bg');
391
+
392
+ edgeG
393
+ .append('text')
394
+ .attr('x', mid.x)
395
+ .attr('y', mid.y + 4)
396
+ .attr('text-anchor', 'middle')
397
+ .attr('fill', edgeColor)
398
+ .attr('font-size', EDGE_LABEL_FONT_SIZE)
399
+ .attr('class', 'sitemap-edge-label')
400
+ .text(edge.label);
401
+ }
402
+ }
403
+
404
+ // --- Render page cards (top layer) ---
405
+ for (const node of layout.nodes) {
406
+ const nodeG = contentG
407
+ .append('g')
408
+ .attr('transform', `translate(${node.x - node.width / 2}, ${node.y})`)
409
+ .attr('class', 'sitemap-node')
410
+ .attr('data-line-number', String(node.lineNumber)) as GSelection;
411
+
412
+ if (node.hasChildren) {
413
+ nodeG.attr('data-node-toggle', node.id)
414
+ .attr('tabindex', '0')
415
+ .attr('role', 'button')
416
+ .attr('aria-expanded', String(!node.hiddenCount));
417
+ }
418
+
419
+ if (onClickItem) {
420
+ nodeG.style('cursor', 'pointer').on('click', () => onClickItem(node.lineNumber));
421
+ }
422
+
423
+ // Tag metadata for legend hover dimming
424
+ if (activeTagGroup) {
425
+ const tagKey = activeTagGroup.toLowerCase();
426
+ const tagVal = node.tagMetadata[tagKey];
427
+ if (tagVal) nodeG.attr(`data-tag-${tagKey}`, tagVal.toLowerCase());
428
+ }
429
+
430
+ const fill = nodeFill(palette, isDark, node.color);
431
+ const stroke = nodeStroke(palette, node.color);
432
+
433
+ // Card background
434
+ nodeG.append('rect')
435
+ .attr('x', 0)
436
+ .attr('y', 0)
437
+ .attr('width', node.width)
438
+ .attr('height', node.height)
439
+ .attr('rx', CARD_RADIUS)
440
+ .attr('fill', fill)
441
+ .attr('stroke', stroke)
442
+ .attr('stroke-width', NODE_STROKE_WIDTH);
443
+
444
+ // Label
445
+ nodeG.append('text')
446
+ .attr('x', node.width / 2)
447
+ .attr('y', HEADER_HEIGHT / 2 + LABEL_FONT_SIZE / 2 - 2)
448
+ .attr('text-anchor', 'middle')
449
+ .attr('fill', palette.text)
450
+ .attr('font-size', LABEL_FONT_SIZE)
451
+ .attr('font-weight', 'bold')
452
+ .text(node.label);
453
+
454
+ // Separator and metadata
455
+ const metaEntries = Object.entries(node.metadata);
456
+ if (metaEntries.length > 0) {
457
+ // Separator line
458
+ nodeG.append('line')
459
+ .attr('x1', 0)
460
+ .attr('y1', HEADER_HEIGHT)
461
+ .attr('x2', node.width)
462
+ .attr('y2', HEADER_HEIGHT)
463
+ .attr('stroke', stroke)
464
+ .attr('stroke-opacity', 0.3);
465
+
466
+ const metaDisplayKeys = metaEntries.map(([k]) => displayNames.get(k) ?? k);
467
+ const maxKeyLen = Math.max(...metaDisplayKeys.map((k) => k.length));
468
+ const valueX = 10 + (maxKeyLen + 2) * (META_FONT_SIZE * 0.6);
469
+
470
+ for (let i = 0; i < metaEntries.length; i++) {
471
+ const [key, value] = metaEntries[i];
472
+ const displayKey = metaDisplayKeys[i];
473
+ const rowY = HEADER_HEIGHT + SEPARATOR_GAP + (i + 1) * META_LINE_HEIGHT - 4;
474
+ const valColor = tagColors.get(`${key}:${value.toLowerCase()}`) ?? palette.text;
475
+
476
+ nodeG.append('text')
477
+ .attr('x', 10)
478
+ .attr('y', rowY)
479
+ .attr('fill', palette.textMuted)
480
+ .attr('font-size', META_FONT_SIZE)
481
+ .text(`${displayKey}:`);
482
+
483
+ nodeG.append('text')
484
+ .attr('x', valueX)
485
+ .attr('y', rowY)
486
+ .attr('fill', valColor)
487
+ .attr('font-size', META_FONT_SIZE)
488
+ .text(value);
489
+ }
490
+ }
491
+
492
+ // Collapsed accent bar
493
+ if (!exportDims && node.hiddenCount && node.hiddenCount > 0) {
494
+ const clipId = `clip-${node.id}`;
495
+ nodeG.append('clipPath').attr('id', clipId)
496
+ .append('rect')
497
+ .attr('width', node.width).attr('height', node.height)
498
+ .attr('rx', CARD_RADIUS);
499
+ nodeG.append('rect')
500
+ .attr('y', node.height - COLLAPSE_BAR_HEIGHT)
501
+ .attr('width', node.width)
502
+ .attr('height', COLLAPSE_BAR_HEIGHT)
503
+ .attr('fill', node.color ?? palette.primary)
504
+ .attr('opacity', 0.5)
505
+ .attr('clip-path', `url(#${clipId})`);
506
+ }
507
+ }
508
+
509
+ // --- Render legend ---
510
+ if (exportDims && hasLegend) {
511
+ // Export mode: render inside the scaled content group
512
+ renderLegend(contentG, layout.legend, palette, isDark, activeTagGroup, undefined, hiddenAttributes);
513
+ }
514
+
515
+ // --- Fixed title + legend (appended AFTER mainG so they paint on top
516
+ // and receive pointer events without being blocked by scaled content) ---
517
+ if (fixedTitle) {
518
+ const titleEl = svg
519
+ .append('text')
520
+ .attr('x', width / 2)
521
+ .attr('y', DIAGRAM_PADDING + TITLE_FONT_SIZE)
522
+ .attr('text-anchor', 'middle')
523
+ .attr('fill', palette.text)
524
+ .attr('font-size', TITLE_FONT_SIZE)
525
+ .attr('font-weight', 'bold')
526
+ .attr('class', 'sitemap-title chart-title')
527
+ .style('font-family', FONT_FAMILY);
528
+
529
+ if (parsed.titleLineNumber) {
530
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
531
+ if (onClickItem) {
532
+ titleEl
533
+ .style('cursor', 'pointer')
534
+ .on('click', () => onClickItem(parsed.titleLineNumber!));
535
+ }
536
+ }
537
+
538
+ titleEl.text(parsed.title!);
539
+ }
540
+
541
+ if (fixedLegend) {
542
+ const legendParent = svg
543
+ .append('g')
544
+ .attr('class', 'sitemap-legend-fixed')
545
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + fixedTitleH})`);
546
+ renderLegend(legendParent, layout.legend, palette, isDark, activeTagGroup, width, hiddenAttributes);
547
+ }
548
+ }
549
+
550
+ // ============================================================
551
+ // Legend rendering
552
+ // ============================================================
553
+
554
+ // Eye icon SVG paths (14×14 viewBox)
555
+ const EYE_OPEN_PATH =
556
+ 'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
557
+ const EYE_CLOSED_PATH =
558
+ 'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';
559
+
560
+ function renderLegend(
561
+ parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
562
+ legendGroups: SitemapLegendGroup[],
563
+ palette: PaletteColors,
564
+ isDark: boolean,
565
+ activeTagGroup?: string | null,
566
+ fixedWidth?: number,
567
+ hiddenAttributes?: Set<string>,
568
+ ): void {
569
+ if (legendGroups.length === 0) return;
570
+
571
+ const visibleGroups = activeTagGroup != null
572
+ ? legendGroups.filter((g) => g.name.toLowerCase() === activeTagGroup.toLowerCase())
573
+ : legendGroups;
574
+
575
+ const groupBg = isDark
576
+ ? mix(palette.surface, palette.bg, 50)
577
+ : mix(palette.surface, palette.bg, 30);
578
+
579
+ // For fixed legend: compute pixel-space positions centered in SVG width
580
+ let fixedPositions: Map<string, number> | undefined;
581
+ if (fixedWidth != null && visibleGroups.length > 0) {
582
+ fixedPositions = new Map();
583
+ const effectiveW = (g: SitemapLegendGroup) =>
584
+ activeTagGroup != null ? g.width : g.minifiedWidth;
585
+ const totalW =
586
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
587
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
588
+ let cx = (fixedWidth - totalW) / 2;
589
+ for (const g of visibleGroups) {
590
+ fixedPositions.set(g.name, cx);
591
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
592
+ }
593
+ }
594
+
595
+ for (const group of visibleGroups) {
596
+ const isActive = activeTagGroup != null;
597
+ const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
598
+
599
+ const gX = fixedPositions?.get(group.name) ?? group.x;
600
+ const gY = fixedPositions ? 0 : group.y;
601
+
602
+ const legendG = parent
603
+ .append('g')
604
+ .attr('transform', `translate(${gX}, ${gY})`)
605
+ .attr('class', 'sitemap-legend-group')
606
+ .attr('data-legend-group', group.name.toLowerCase())
607
+ .style('cursor', 'pointer');
608
+
609
+ // Outer capsule background (active/expanded only)
610
+ if (isActive) {
611
+ legendG.append('rect')
612
+ .attr('width', group.width)
613
+ .attr('height', LEGEND_HEIGHT)
614
+ .attr('rx', LEGEND_HEIGHT / 2)
615
+ .attr('fill', groupBg);
616
+ }
617
+
618
+ const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
619
+ const pillYOff = isActive ? LEGEND_CAPSULE_PAD : 0;
620
+ const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
621
+
622
+ // Pill background
623
+ legendG.append('rect')
624
+ .attr('x', pillXOff)
625
+ .attr('y', pillYOff)
626
+ .attr('width', pillW)
627
+ .attr('height', pillH)
628
+ .attr('rx', pillH / 2)
629
+ .attr('fill', isActive ? palette.bg : groupBg);
630
+
631
+ // Active pill border
632
+ if (isActive) {
633
+ legendG.append('rect')
634
+ .attr('x', pillXOff)
635
+ .attr('y', pillYOff)
636
+ .attr('width', pillW)
637
+ .attr('height', pillH)
638
+ .attr('rx', pillH / 2)
639
+ .attr('fill', 'none')
640
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
641
+ .attr('stroke-width', 0.75);
642
+ }
643
+
644
+ // Pill text
645
+ legendG.append('text')
646
+ .attr('x', pillXOff + pillW / 2)
647
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
648
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
649
+ .attr('font-weight', '500')
650
+ .attr('fill', isActive ? palette.text : palette.textMuted)
651
+ .attr('text-anchor', 'middle')
652
+ .text(group.name);
653
+
654
+ // Eye icon for visibility toggle (active only, app mode)
655
+ if (isActive && fixedWidth != null) {
656
+ const groupKey = group.name.toLowerCase();
657
+ const isHidden = hiddenAttributes?.has(groupKey) ?? false;
658
+ const eyeX = pillXOff + pillW + LEGEND_EYE_GAP;
659
+ const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
660
+ const hitPad = 6;
661
+
662
+ const eyeG = legendG.append('g')
663
+ .attr('class', 'sitemap-legend-eye')
664
+ .attr('data-legend-visibility', groupKey)
665
+ .style('cursor', 'pointer')
666
+ .attr('opacity', isHidden ? 0.4 : 0.7);
667
+
668
+ // Transparent hit area for easier clicking
669
+ eyeG.append('rect')
670
+ .attr('x', eyeX - hitPad)
671
+ .attr('y', eyeY - hitPad)
672
+ .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
673
+ .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
674
+ .attr('fill', 'transparent')
675
+ .attr('pointer-events', 'all');
676
+
677
+ eyeG.append('path')
678
+ .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
679
+ .attr('transform', `translate(${eyeX}, ${eyeY})`)
680
+ .attr('fill', 'none')
681
+ .attr('stroke', palette.textMuted)
682
+ .attr('stroke-width', 1.2)
683
+ .attr('stroke-linecap', 'round')
684
+ .attr('stroke-linejoin', 'round');
685
+ }
686
+
687
+ // Entries (active/expanded only)
688
+ if (isActive) {
689
+ const eyeShift = fixedWidth != null ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
690
+ let entryX = pillXOff + pillW + 4 + eyeShift;
691
+ for (const entry of group.entries) {
692
+ const entryG = legendG.append('g')
693
+ .attr('data-legend-entry', entry.value.toLowerCase())
694
+ .style('cursor', 'pointer');
695
+
696
+ entryG.append('circle')
697
+ .attr('cx', entryX + LEGEND_DOT_R)
698
+ .attr('cy', LEGEND_HEIGHT / 2)
699
+ .attr('r', LEGEND_DOT_R)
700
+ .attr('fill', entry.color);
701
+
702
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
703
+ entryG.append('text')
704
+ .attr('x', textX)
705
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
706
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
707
+ .attr('fill', palette.textMuted)
708
+ .text(entry.value);
709
+
710
+ entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
711
+ }
712
+ }
713
+ }
714
+ }
715
+
716
+ // ============================================================
717
+ // Export convenience function
718
+ // ============================================================
719
+
720
+ export async function renderSitemapForExport(
721
+ content: string,
722
+ theme: 'light' | 'dark' | 'transparent',
723
+ palette?: PaletteColors,
724
+ ): Promise<string> {
725
+ const { parseSitemap } = await import('./parser');
726
+ const { layoutSitemap } = await import('./layout');
727
+ const { getPalette } = await import('../palettes');
728
+ const { injectBranding } = await import('../branding');
729
+
730
+ const isDark = theme === 'dark';
731
+ const effectivePalette = palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
732
+
733
+ const parsed = parseSitemap(content, effectivePalette);
734
+ if (parsed.error || parsed.roots.length === 0) return '';
735
+
736
+ const sitemapLayout = layoutSitemap(parsed, undefined, null, undefined, true);
737
+
738
+ const PADDING = 20;
739
+ const titleOffset = parsed.title ? 30 : 0;
740
+ const exportWidth = sitemapLayout.width + PADDING * 2;
741
+ const exportHeight = sitemapLayout.height + PADDING * 2 + titleOffset;
742
+
743
+ const container = document.createElement('div');
744
+ container.style.width = `${exportWidth}px`;
745
+ container.style.height = `${exportHeight}px`;
746
+ container.style.position = 'absolute';
747
+ container.style.left = '-9999px';
748
+ document.body.appendChild(container);
749
+
750
+ renderSitemap(container, parsed, sitemapLayout, effectivePalette, isDark, undefined, {
751
+ width: exportWidth,
752
+ height: exportHeight,
753
+ });
754
+
755
+ const svgEl = container.querySelector('svg');
756
+ if (!svgEl) {
757
+ document.body.removeChild(container);
758
+ return '';
759
+ }
760
+
761
+ if (theme === 'transparent') {
762
+ svgEl.style.background = 'none';
763
+ } else if (!svgEl.style.background) {
764
+ svgEl.style.background = effectivePalette.bg;
765
+ }
766
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
767
+ svgEl.style.fontFamily = FONT_FAMILY;
768
+
769
+ const svgHtml = svgEl.outerHTML;
770
+ document.body.removeChild(container);
771
+
772
+ const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
773
+ return injectBranding(svgHtml, brandColor);
774
+ }