@diagrammo/dgmo 0.6.2 → 0.7.0

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 (61) hide show
  1. package/.claude/commands/dgmo.md +231 -13
  2. package/AGENTS.md +148 -0
  3. package/dist/cli.cjs +341 -165
  4. package/dist/index.cjs +4900 -1685
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +259 -18
  7. package/dist/index.d.ts +259 -18
  8. package/dist/index.js +4642 -1436
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -3
  11. package/src/c4/layout.ts +0 -5
  12. package/src/c4/parser.ts +0 -16
  13. package/src/c4/renderer.ts +7 -11
  14. package/src/class/layout.ts +0 -1
  15. package/src/class/parser.ts +28 -0
  16. package/src/class/renderer.ts +189 -34
  17. package/src/cli.ts +566 -25
  18. package/src/colors.ts +3 -3
  19. package/src/completion.ts +58 -0
  20. package/src/d3.ts +179 -122
  21. package/src/dgmo-router.ts +3 -58
  22. package/src/echarts.ts +96 -55
  23. package/src/er/parser.ts +30 -1
  24. package/src/er/renderer.ts +12 -7
  25. package/src/gantt/calculator.ts +677 -0
  26. package/src/gantt/parser.ts +761 -0
  27. package/src/gantt/renderer.ts +2125 -0
  28. package/src/gantt/resolver.ts +144 -0
  29. package/src/gantt/types.ts +168 -0
  30. package/src/graph/flowchart-parser.ts +27 -4
  31. package/src/graph/flowchart-renderer.ts +1 -2
  32. package/src/graph/state-parser.ts +0 -1
  33. package/src/graph/state-renderer.ts +1 -3
  34. package/src/index.ts +37 -0
  35. package/src/infra/compute.ts +0 -7
  36. package/src/infra/layout.ts +0 -2
  37. package/src/infra/parser.ts +46 -4
  38. package/src/infra/renderer.ts +49 -27
  39. package/src/initiative-status/filter.ts +63 -0
  40. package/src/initiative-status/layout.ts +319 -67
  41. package/src/initiative-status/parser.ts +200 -25
  42. package/src/initiative-status/renderer.ts +298 -35
  43. package/src/initiative-status/types.ts +6 -0
  44. package/src/kanban/parser.ts +0 -2
  45. package/src/org/layout.ts +22 -59
  46. package/src/org/renderer.ts +11 -36
  47. package/src/palettes/dracula.ts +60 -0
  48. package/src/palettes/index.ts +8 -6
  49. package/src/palettes/monokai.ts +60 -0
  50. package/src/palettes/registry.ts +4 -2
  51. package/src/sequence/parser.ts +14 -11
  52. package/src/sequence/renderer.ts +5 -6
  53. package/src/sequence/tag-resolution.ts +0 -1
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/layout.ts +1 -14
  56. package/src/sitemap/parser.ts +1 -2
  57. package/src/sitemap/renderer.ts +4 -7
  58. package/src/utils/arrows.ts +7 -7
  59. package/src/utils/duration.ts +212 -0
  60. package/src/utils/export-container.ts +40 -0
  61. package/src/utils/legend-constants.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,8 @@
29
29
  ".claude/commands",
30
30
  ".github/copilot-instructions.md",
31
31
  ".cursorrules",
32
- ".windsurfrules"
32
+ ".windsurfrules",
33
+ "AGENTS.md"
33
34
  ],
34
35
  "sideEffects": false,
35
36
  "scripts": {
@@ -40,6 +41,7 @@
40
41
  "test:watch": "vitest",
41
42
  "gallery": "pnpm build && node scripts/generate-gallery.mjs",
42
43
  "check:duplication": "jscpd ./src",
44
+ "check:deadcode": "knip",
43
45
  "postinstall": "node -e \"console.log('\\nšŸ’” Claude Code user? Run: dgmo --install-claude-skill\\n')\""
44
46
  },
45
47
  "dependencies": {
@@ -64,9 +66,9 @@
64
66
  "@types/d3-scale": "^4.0.8",
65
67
  "@types/d3-selection": "^3.0.11",
66
68
  "@types/d3-shape": "^3.1.7",
67
- "@types/dagre": "^0.7.54",
68
69
  "@types/jsdom": "^28.0.0",
69
70
  "jscpd": "^4.0.8",
71
+ "knip": "^6.0.1",
70
72
  "tsup": "^8.5.1",
71
73
  "typescript": "^5.7.3",
72
74
  "vitest": "^4.0.18"
package/src/c4/layout.ts CHANGED
@@ -86,7 +86,6 @@ const DESC_LINE_HEIGHT = 16;
86
86
  const DESC_CHAR_WIDTH = 6.5;
87
87
  const CARD_V_PAD = 14;
88
88
  const CARD_H_PAD = 20;
89
- const TECH_LINE_HEIGHT = 16;
90
89
  const META_LINE_HEIGHT = 16;
91
90
  const META_CHAR_WIDTH = 6.5;
92
91
  const MARGIN = 40;
@@ -478,10 +477,6 @@ export function rollUpContextRelationships(parsed: ParsedC4): ContextRelationshi
478
477
  const allRels = collectAllRelationships(parsed.elements, ownerMap);
479
478
 
480
479
  // Also include orphan relationships
481
- for (const rel of parsed.relationships) {
482
- // Orphan rels have no source element name — skip them for context roll-up
483
- }
484
-
485
480
  // Separate system-level (explicit) from nested (rolled-up)
486
481
  const topLevelNames = new Set(parsed.elements.map((e) => e.name));
487
482
  const explicitKeys = new Set<string>();
package/src/c4/parser.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  // ============================================================
4
4
 
5
5
  import type { PaletteColors } from '../palettes';
6
- import type { DgmoError } from '../diagnostics';
7
6
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
7
  import type { TagGroup } from '../utils/tag-groups';
9
8
  import { matchTagBlockHeading } from '../utils/tag-groups';
@@ -751,21 +750,6 @@ function attachElement(
751
750
  // Post-parse validation
752
751
  // ============================================================
753
752
 
754
- function collectAllNames(result: ParsedC4): Map<string, number> {
755
- const names = new Map<string, number>();
756
- function walk(elements: C4Element[]) {
757
- for (const el of elements) {
758
- names.set(el.name.toLowerCase(), el.lineNumber);
759
- walk(el.children);
760
- for (const g of el.groups) {
761
- walk(g.children);
762
- }
763
- }
764
- }
765
- walk(result.elements);
766
- return names;
767
- }
768
-
769
753
  function validateRelationshipTargets(
770
754
  result: ParsedC4,
771
755
  knownNames: Map<string, number>,
@@ -9,8 +9,7 @@ import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { renderInlineText } from '../utils/inline-markdown';
11
11
  import type { ParsedC4 } from './types';
12
- import type { C4Shape } from './types';
13
- import type { C4LayoutResult, C4LayoutNode, C4LayoutEdge, C4LayoutBoundary, C4LegendGroup } from './layout';
12
+ import type { C4LayoutResult, C4LayoutEdge, C4LegendGroup } from './layout';
14
13
  import { parseC4 } from './parser';
15
14
  import { layoutC4Context, layoutC4Containers, layoutC4Components, layoutC4Deployment, collectCardMetadata } from './layout';
16
15
  import {
@@ -50,7 +49,6 @@ const CARD_V_PAD = 14;
50
49
  const TYPE_LABEL_HEIGHT = 18;
51
50
  const DIVIDER_GAP = 6;
52
51
  const NAME_HEIGHT = 20;
53
- const TECH_LINE_HEIGHT = 16;
54
52
  const META_FONT_SIZE = 11;
55
53
  const META_CHAR_WIDTH = 6.5;
56
54
  const META_LINE_HEIGHT = 16;
@@ -257,9 +255,8 @@ export function renderC4Context(
257
255
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
258
256
 
259
257
  const scaledW = diagramW * scale;
260
- const scaledH = diagramH * scale;
261
258
  const offsetX = (width - scaledW) / 2;
262
- const offsetY = titleHeight + DIAGRAM_PADDING;
259
+ const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
263
260
 
264
261
  const svg = d3Selection
265
262
  .select(container)
@@ -595,12 +592,12 @@ export function renderC4Context(
595
592
 
596
593
  // ── Legend ──
597
594
  if (hasLegend) {
598
- // App mode: fixed overlay at SVG bottom so it's always readable regardless of scale.
595
+ // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
599
596
  // Export mode: render inside scaled contentG at layout coordinates.
600
597
  const legendParent = fixedLegend
601
598
  ? svg.append('g')
602
599
  .attr('class', 'c4-legend-fixed')
603
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`)
600
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
604
601
  : contentG.append('g').attr('class', 'c4-legend');
605
602
  if (activeTagGroup) {
606
603
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
@@ -1300,9 +1297,8 @@ export function renderC4Containers(
1300
1297
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
1301
1298
 
1302
1299
  const scaledW = diagramW * scale;
1303
- const scaledH = diagramH * scale;
1304
1300
  const offsetX = (width - scaledW) / 2;
1305
- const offsetY = titleHeight + DIAGRAM_PADDING;
1301
+ const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
1306
1302
 
1307
1303
  const svg = d3Selection
1308
1304
  .select(container)
@@ -1711,12 +1707,12 @@ export function renderC4Containers(
1711
1707
 
1712
1708
  // ── Legend ──
1713
1709
  if (hasLegend) {
1714
- // App mode: fixed overlay at SVG bottom so it's always readable regardless of scale.
1710
+ // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
1715
1711
  // Export mode: render inside scaled contentG at layout coordinates.
1716
1712
  const legendParent = fixedLegend
1717
1713
  ? svg.append('g')
1718
1714
  .attr('class', 'c4-legend-fixed')
1719
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`)
1715
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
1720
1716
  : contentG.append('g').attr('class', 'c4-legend');
1721
1717
  if (activeTagGroup) {
1722
1718
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
@@ -2,7 +2,6 @@ import dagre from '@dagrejs/dagre';
2
2
  import type {
3
3
  ParsedClassDiagram,
4
4
  ClassNode,
5
- ClassRelationship,
6
5
  RelationshipType,
7
6
  } from './types';
8
7
 
@@ -383,3 +383,31 @@ export function looksLikeClassDiagram(content: string): boolean {
383
383
 
384
384
  return false;
385
385
  }
386
+
387
+ // ============================================================
388
+ // Symbol extraction (for completion API)
389
+ // ============================================================
390
+
391
+ import type { DiagramSymbols } from '../completion';
392
+
393
+ /**
394
+ * Extract class names (entities) from class diagram document text.
395
+ * Used by the dgmo completion API for ghost hints and popup completions.
396
+ */
397
+ export function extractSymbols(docText: string): DiagramSymbols {
398
+ const entities: string[] = [];
399
+ let inMetadata = true;
400
+ for (const rawLine of docText.split('\n')) {
401
+ const line = rawLine.trim();
402
+ if (inMetadata && /^[a-z-]+\s*:/i.test(line)) continue;
403
+ inMetadata = false;
404
+ if (line.length === 0 || /^\s/.test(rawLine)) continue;
405
+ const m = CLASS_DECL_RE.exec(line);
406
+ if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
407
+ }
408
+ return {
409
+ kind: 'class',
410
+ entities,
411
+ keywords: ['extends', 'implements', 'abstract', 'interface', 'enum'],
412
+ };
413
+ }
@@ -5,10 +5,23 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
+ import { runInExportContainer, extractExportSvg } from '../utils/export-container';
9
+ import {
10
+ LEGEND_HEIGHT,
11
+ LEGEND_PILL_PAD,
12
+ LEGEND_PILL_FONT_SIZE,
13
+ LEGEND_PILL_FONT_W,
14
+ LEGEND_CAPSULE_PAD,
15
+ LEGEND_DOT_R,
16
+ LEGEND_ENTRY_FONT_SIZE,
17
+ LEGEND_ENTRY_FONT_W,
18
+ LEGEND_ENTRY_DOT_GAP,
19
+ LEGEND_ENTRY_TRAIL,
20
+ } from '../utils/legend-constants';
8
21
  import type { PaletteColors } from '../palettes';
9
22
  import { mix } from '../palettes/color-utils';
10
23
  import type { ParsedClassDiagram, ClassModifier, RelationshipType } from './types';
11
- import type { ClassLayoutResult, ClassLayoutNode, ClassLayoutEdge } from './layout';
24
+ import type { ClassLayoutResult } from './layout';
12
25
  import { parseClassDiagram } from './parser';
13
26
  import { layoutClassDiagram } from './layout';
14
27
 
@@ -50,6 +63,49 @@ function nodeStroke(palette: PaletteColors, modifier: ClassModifier | undefined,
50
63
  return nodeColor ?? modifierColor(modifier, palette, colorOff);
51
64
  }
52
65
 
66
+ // ============================================================
67
+ // Legend helpers
68
+ // ============================================================
69
+
70
+ interface ClassLegendEntry {
71
+ label: string;
72
+ colorKey: keyof PaletteColors['colors'];
73
+ }
74
+
75
+ const CLASS_TYPE_MAP: Record<string, ClassLegendEntry> = {
76
+ class: { label: 'Class', colorKey: 'teal' },
77
+ abstract: { label: 'Abstract', colorKey: 'purple' },
78
+ interface: { label: 'Interface', colorKey: 'blue' },
79
+ enum: { label: 'Enum', colorKey: 'yellow' },
80
+ };
81
+
82
+ const CLASS_TYPE_ORDER = ['class', 'abstract', 'interface', 'enum'];
83
+
84
+ function collectClassTypes(parsed: ParsedClassDiagram): ClassLegendEntry[] {
85
+ if (parsed.options?.color === 'off') return [];
86
+
87
+ const present = new Set<string>();
88
+ for (const c of parsed.classes) {
89
+ if (c.color) continue; // explicit color override — skip
90
+ present.add(c.modifier ?? 'class');
91
+ }
92
+ return CLASS_TYPE_ORDER.filter((k) => present.has(k)).map((k) => CLASS_TYPE_MAP[k]);
93
+ }
94
+
95
+ const LEGEND_GROUP_NAME = 'Type';
96
+
97
+ function legendEntriesWidth(entries: ClassLegendEntry[]): number {
98
+ let w = 0;
99
+ for (const e of entries) {
100
+ w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
101
+ }
102
+ return w;
103
+ }
104
+
105
+ function classTypeKey(modifier: ClassModifier | undefined): string {
106
+ return modifier ?? 'class';
107
+ }
108
+
53
109
  // ============================================================
54
110
  // Visibility symbols
55
111
  // ============================================================
@@ -99,8 +155,6 @@ function isSourceMarker(type: RelationshipType): boolean {
99
155
  // Main renderer
100
156
  // ============================================================
101
157
 
102
- type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
103
-
104
158
  export function renderClassDiagram(
105
159
  container: HTMLDivElement,
106
160
  parsed: ParsedClassDiagram,
@@ -108,7 +162,8 @@ export function renderClassDiagram(
108
162
  palette: PaletteColors,
109
163
  isDark: boolean,
110
164
  onClickItem?: (lineNumber: number) => void,
111
- exportDims?: { width?: number; height?: number }
165
+ exportDims?: { width?: number; height?: number },
166
+ legendActive?: boolean | null
112
167
  ): void {
113
168
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
114
169
 
@@ -116,18 +171,22 @@ export function renderClassDiagram(
116
171
  const height = exportDims?.height ?? container.clientHeight;
117
172
  if (width <= 0 || height <= 0) return;
118
173
 
174
+ const legendEntries = collectClassTypes(parsed);
175
+ const hasLegend = legendEntries.length > 1; // only show when multiple types present
176
+
119
177
  const titleHeight = parsed.title ? 40 : 0;
178
+ const LEGEND_FIXED_GAP = 8;
179
+ const legendReserve = hasLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
120
180
  const diagramW = layout.width;
121
181
  const diagramH = layout.height;
122
- const availH = height - titleHeight;
182
+ const availH = height - titleHeight - legendReserve;
123
183
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
124
184
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
125
185
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
126
186
 
127
187
  const scaledW = diagramW * scale;
128
- const scaledH = diagramH * scale;
129
188
  const offsetX = (width - scaledW) / 2;
130
- const offsetY = titleHeight + DIAGRAM_PADDING;
189
+ const offsetY = titleHeight + legendReserve + DIAGRAM_PADDING;
131
190
 
132
191
  const svg = d3Selection
133
192
  .select(container)
@@ -246,6 +305,114 @@ export function renderClassDiagram(
246
305
  }
247
306
  }
248
307
 
308
+ // ── Legend ──
309
+ // legendActive: true = expanded (default), false = collapsed pill only
310
+ const isLegendExpanded = legendActive !== false;
311
+ if (hasLegend) {
312
+ const groupBg = isDark
313
+ ? mix(palette.surface, palette.bg, 50)
314
+ : mix(palette.surface, palette.bg, 30);
315
+
316
+ const pillWidth = LEGEND_GROUP_NAME.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
317
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
318
+ const entriesW = legendEntriesWidth(legendEntries);
319
+
320
+ const totalW = isLegendExpanded
321
+ ? LEGEND_CAPSULE_PAD * 2 + pillWidth + LEGEND_ENTRY_TRAIL + entriesW
322
+ : pillWidth;
323
+
324
+ const legendX = (width - totalW) / 2;
325
+ const legendY = titleHeight;
326
+
327
+ const legendG = svg
328
+ .append('g')
329
+ .attr('class', 'cd-legend')
330
+ .attr('data-legend-group', 'type')
331
+ .attr('transform', `translate(${legendX}, ${legendY})`)
332
+ .style('cursor', 'pointer');
333
+
334
+ if (isLegendExpanded) {
335
+ // Outer capsule
336
+ legendG.append('rect')
337
+ .attr('width', totalW)
338
+ .attr('height', LEGEND_HEIGHT)
339
+ .attr('rx', LEGEND_HEIGHT / 2)
340
+ .attr('fill', groupBg);
341
+
342
+ // Inner pill
343
+ legendG.append('rect')
344
+ .attr('x', LEGEND_CAPSULE_PAD)
345
+ .attr('y', LEGEND_CAPSULE_PAD)
346
+ .attr('width', pillWidth)
347
+ .attr('height', pillH)
348
+ .attr('rx', pillH / 2)
349
+ .attr('fill', palette.bg);
350
+
351
+ legendG.append('rect')
352
+ .attr('x', LEGEND_CAPSULE_PAD)
353
+ .attr('y', LEGEND_CAPSULE_PAD)
354
+ .attr('width', pillWidth)
355
+ .attr('height', pillH)
356
+ .attr('rx', pillH / 2)
357
+ .attr('fill', 'none')
358
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
359
+ .attr('stroke-width', 0.75);
360
+
361
+ legendG.append('text')
362
+ .attr('x', LEGEND_CAPSULE_PAD + pillWidth / 2)
363
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
364
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
365
+ .attr('font-weight', '500')
366
+ .attr('fill', palette.text)
367
+ .attr('text-anchor', 'middle')
368
+ .attr('font-family', FONT_FAMILY)
369
+ .text(LEGEND_GROUP_NAME);
370
+
371
+ // Entries
372
+ let entryX = LEGEND_CAPSULE_PAD + pillWidth + LEGEND_ENTRY_TRAIL;
373
+ for (const entry of legendEntries) {
374
+ const color = palette.colors[entry.colorKey];
375
+ const typeKey = CLASS_TYPE_ORDER.find((k) => CLASS_TYPE_MAP[k] === entry)!;
376
+
377
+ const entryG = legendG.append('g')
378
+ .attr('data-legend-entry', typeKey);
379
+
380
+ entryG.append('circle')
381
+ .attr('cx', entryX + LEGEND_DOT_R)
382
+ .attr('cy', LEGEND_HEIGHT / 2)
383
+ .attr('r', LEGEND_DOT_R)
384
+ .attr('fill', color);
385
+
386
+ entryG.append('text')
387
+ .attr('x', entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
388
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
389
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
390
+ .attr('fill', palette.textMuted)
391
+ .attr('font-family', FONT_FAMILY)
392
+ .text(entry.label);
393
+
394
+ entryX += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
395
+ }
396
+ } else {
397
+ // Collapsed: single muted pill
398
+ legendG.append('rect')
399
+ .attr('width', pillWidth)
400
+ .attr('height', LEGEND_HEIGHT)
401
+ .attr('rx', LEGEND_HEIGHT / 2)
402
+ .attr('fill', groupBg);
403
+
404
+ legendG.append('text')
405
+ .attr('x', pillWidth / 2)
406
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
407
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
408
+ .attr('font-weight', '500')
409
+ .attr('fill', palette.textMuted)
410
+ .attr('text-anchor', 'middle')
411
+ .attr('font-family', FONT_FAMILY)
412
+ .text(LEGEND_GROUP_NAME);
413
+ }
414
+ }
415
+
249
416
  // ── Content group ──
250
417
  const contentG = svg
251
418
  .append('g')
@@ -322,7 +489,8 @@ export function renderClassDiagram(
322
489
  .attr('transform', `translate(${node.x}, ${node.y})`)
323
490
  .attr('class', 'cd-class')
324
491
  .attr('data-line-number', String(node.lineNumber))
325
- .attr('data-node-id', node.id);
492
+ .attr('data-node-id', node.id)
493
+ .attr('data-cd-type', classTypeKey(node.modifier));
326
494
 
327
495
  if (onClickItem) {
328
496
  nodeG.style('cursor', 'pointer').on('click', () => {
@@ -333,8 +501,11 @@ export function renderClassDiagram(
333
501
  const w = node.width;
334
502
  const h = node.height;
335
503
  const colorOff = parsed.options?.color === 'off';
336
- const fill = nodeFill(palette, isDark, node.modifier, node.color, colorOff);
337
- const stroke = nodeStroke(palette, node.modifier, node.color, colorOff);
504
+ // When legend is collapsed, use neutral color for nodes without explicit color
505
+ const neutralize = hasLegend && !isLegendExpanded && !node.color;
506
+ const effectiveColor = neutralize ? palette.primary : node.color;
507
+ const fill = nodeFill(palette, isDark, node.modifier, effectiveColor, colorOff);
508
+ const stroke = nodeStroke(palette, node.modifier, effectiveColor, colorOff);
338
509
 
339
510
  // Outer rectangle
340
511
  nodeG.append('rect')
@@ -506,16 +677,13 @@ export function renderClassDiagramForExport(
506
677
  const layout = layoutClassDiagram(parsed);
507
678
  const isDark = theme === 'dark';
508
679
 
509
- const container = document.createElement('div');
680
+ const legendEntries = collectClassTypes(parsed);
681
+ const EXPORT_LEGEND_GAP = 8;
682
+ const legendReserve = legendEntries.length > 1 ? LEGEND_HEIGHT + EXPORT_LEGEND_GAP : 0;
510
683
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
511
- const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0);
512
- container.style.width = `${exportWidth}px`;
513
- container.style.height = `${exportHeight}px`;
514
- container.style.position = 'absolute';
515
- container.style.left = '-9999px';
516
- document.body.appendChild(container);
517
-
518
- try {
684
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0) + legendReserve;
685
+
686
+ return runInExportContainer(exportWidth, exportHeight, (container) => {
519
687
  renderClassDiagram(
520
688
  container,
521
689
  parsed,
@@ -525,19 +693,6 @@ export function renderClassDiagramForExport(
525
693
  undefined,
526
694
  { width: exportWidth, height: exportHeight }
527
695
  );
528
-
529
- const svgEl = container.querySelector('svg');
530
- if (!svgEl) return '';
531
-
532
- if (theme === 'transparent') {
533
- svgEl.style.background = 'none';
534
- }
535
-
536
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
537
- svgEl.style.fontFamily = FONT_FAMILY;
538
-
539
- return svgEl.outerHTML;
540
- } finally {
541
- document.body.removeChild(container);
542
- }
696
+ return extractExportSvg(container, theme);
697
+ });
543
698
  }