@diagrammo/dgmo 0.8.9 → 0.8.11

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 (46) hide show
  1. package/AGENTS.md +3 -0
  2. package/dist/cli.cjs +245 -672
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.d.cts +2 -3
  5. package/dist/editor.d.ts +2 -3
  6. package/dist/editor.js.map +1 -1
  7. package/dist/index.cjs +1623 -800
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +153 -1
  10. package/dist/index.d.ts +153 -1
  11. package/dist/index.js +1619 -802
  12. package/dist/index.js.map +1 -1
  13. package/docs/language-reference.md +28 -2
  14. package/gallery/fixtures/sitemap-full.dgmo +1 -0
  15. package/package.json +14 -17
  16. package/src/boxes-and-lines/layout.ts +48 -8
  17. package/src/boxes-and-lines/parser.ts +59 -13
  18. package/src/boxes-and-lines/renderer.ts +34 -138
  19. package/src/c4/layout.ts +31 -10
  20. package/src/c4/renderer.ts +25 -138
  21. package/src/class/renderer.ts +185 -186
  22. package/src/d3.ts +194 -222
  23. package/src/echarts.ts +56 -57
  24. package/src/editor/index.ts +1 -2
  25. package/src/er/renderer.ts +52 -245
  26. package/src/gantt/renderer.ts +140 -182
  27. package/src/gantt/resolver.ts +19 -14
  28. package/src/index.ts +23 -1
  29. package/src/infra/renderer.ts +91 -244
  30. package/src/kanban/renderer.ts +29 -133
  31. package/src/label-layout.ts +286 -0
  32. package/src/org/renderer.ts +103 -170
  33. package/src/render.ts +39 -9
  34. package/src/sequence/parser.ts +4 -0
  35. package/src/sequence/renderer.ts +47 -154
  36. package/src/sitemap/layout.ts +180 -38
  37. package/src/sitemap/parser.ts +64 -23
  38. package/src/sitemap/renderer.ts +73 -161
  39. package/src/utils/arrows.ts +1 -1
  40. package/src/utils/legend-constants.ts +6 -0
  41. package/src/utils/legend-d3.ts +400 -0
  42. package/src/utils/legend-layout.ts +491 -0
  43. package/src/utils/legend-svg.ts +28 -2
  44. package/src/utils/legend-types.ts +166 -0
  45. package/src/utils/parsing.ts +1 -1
  46. package/src/utils/tag-groups.ts +1 -1
@@ -0,0 +1,286 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared label collision detection and placement utilities
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface LabelRect {
6
+ x: number;
7
+ y: number;
8
+ w: number;
9
+ h: number;
10
+ }
11
+
12
+ export interface PointCircle {
13
+ cx: number;
14
+ cy: number;
15
+ r: number;
16
+ }
17
+
18
+ /** Axis-aligned bounding box overlap test. */
19
+ export function rectsOverlap(a: LabelRect, b: LabelRect): boolean {
20
+ return (
21
+ a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
22
+ );
23
+ }
24
+
25
+ /** Rect vs circle overlap using nearest-point-on-rect distance check. */
26
+ export function rectCircleOverlap(
27
+ rect: LabelRect,
28
+ circle: PointCircle
29
+ ): boolean {
30
+ const nearestX = Math.max(rect.x, Math.min(circle.cx, rect.x + rect.w));
31
+ const nearestY = Math.max(rect.y, Math.min(circle.cy, rect.y + rect.h));
32
+ const dx = nearestX - circle.cx;
33
+ const dy = nearestY - circle.cy;
34
+ return dx * dx + dy * dy < circle.r * circle.r;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Quadrant chart point label placement
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const CHAR_WIDTH_RATIO = 0.6;
42
+
43
+ export interface QuadrantLabelPoint {
44
+ label: string;
45
+ cx: number; // pixel x
46
+ cy: number; // pixel y
47
+ }
48
+
49
+ interface PlacedQuadrantLabel {
50
+ label: string;
51
+ x: number; // text x
52
+ y: number; // text y (center of label)
53
+ anchor: 'middle' | 'start' | 'end';
54
+ connectorLine?: { x1: number; y1: number; x2: number; y2: number };
55
+ }
56
+
57
+ /**
58
+ * Greedy label placement for quadrant chart points.
59
+ * Avoids collisions with other point labels, point circles, and obstacle rects
60
+ * (quadrant watermark labels). Labels are constrained within chartBounds.
61
+ *
62
+ * Pure function — no DOM dependency.
63
+ */
64
+ export function computeQuadrantPointLabels(
65
+ points: QuadrantLabelPoint[],
66
+ chartBounds: { left: number; top: number; right: number; bottom: number },
67
+ obstacles: LabelRect[],
68
+ pointRadius: number,
69
+ fontSize: number
70
+ ): PlacedQuadrantLabel[] {
71
+ const labelHeight = fontSize + 4;
72
+ const stepSize = labelHeight + 2;
73
+ const minGap = pointRadius + 4;
74
+
75
+ // Build collision circles for all points
76
+ const pointCircles: PointCircle[] = points.map((p) => ({
77
+ cx: p.cx,
78
+ cy: p.cy,
79
+ r: pointRadius,
80
+ }));
81
+
82
+ const placedLabels: LabelRect[] = [];
83
+ const results: PlacedQuadrantLabel[] = [];
84
+
85
+ for (let i = 0; i < points.length; i++) {
86
+ const pt = points[i];
87
+ const labelWidth = pt.label.length * fontSize * CHAR_WIDTH_RATIO + 8;
88
+
89
+ // Try 4 directions: above, below, left, right
90
+ // Each direction generates candidate (labelX, labelY, anchor)
91
+ type Candidate = {
92
+ rect: LabelRect;
93
+ textX: number;
94
+ textY: number;
95
+ anchor: 'middle' | 'start' | 'end';
96
+ dist: number;
97
+ };
98
+
99
+ let best: Candidate | null = null;
100
+
101
+ // Direction generators: for a given offset, produce a candidate rect + text position
102
+ const directions: Array<{
103
+ gen: (offset: number) => {
104
+ rect: LabelRect;
105
+ textX: number;
106
+ textY: number;
107
+ anchor: 'middle' | 'start' | 'end';
108
+ } | null;
109
+ }> = [
110
+ {
111
+ // Above
112
+ gen: (offset) => {
113
+ const lx = pt.cx - labelWidth / 2;
114
+ const ly = pt.cy - offset - labelHeight;
115
+ if (
116
+ ly < chartBounds.top ||
117
+ lx < chartBounds.left ||
118
+ lx + labelWidth > chartBounds.right
119
+ )
120
+ return null;
121
+ return {
122
+ rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
123
+ textX: pt.cx,
124
+ textY: ly + labelHeight / 2,
125
+ anchor: 'middle',
126
+ };
127
+ },
128
+ },
129
+ {
130
+ // Below
131
+ gen: (offset) => {
132
+ const lx = pt.cx - labelWidth / 2;
133
+ const ly = pt.cy + offset;
134
+ if (
135
+ ly + labelHeight > chartBounds.bottom ||
136
+ lx < chartBounds.left ||
137
+ lx + labelWidth > chartBounds.right
138
+ )
139
+ return null;
140
+ return {
141
+ rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
142
+ textX: pt.cx,
143
+ textY: ly + labelHeight / 2,
144
+ anchor: 'middle',
145
+ };
146
+ },
147
+ },
148
+ {
149
+ // Right
150
+ gen: (offset) => {
151
+ const lx = pt.cx + offset;
152
+ const ly = pt.cy - labelHeight / 2;
153
+ if (
154
+ lx + labelWidth > chartBounds.right ||
155
+ ly < chartBounds.top ||
156
+ ly + labelHeight > chartBounds.bottom
157
+ )
158
+ return null;
159
+ return {
160
+ rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
161
+ textX: lx,
162
+ textY: pt.cy,
163
+ anchor: 'start',
164
+ };
165
+ },
166
+ },
167
+ {
168
+ // Left
169
+ gen: (offset) => {
170
+ const lx = pt.cx - offset - labelWidth;
171
+ const ly = pt.cy - labelHeight / 2;
172
+ if (
173
+ lx < chartBounds.left ||
174
+ ly < chartBounds.top ||
175
+ ly + labelHeight > chartBounds.bottom
176
+ )
177
+ return null;
178
+ return {
179
+ rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
180
+ textX: lx + labelWidth,
181
+ textY: pt.cy,
182
+ anchor: 'end',
183
+ };
184
+ },
185
+ },
186
+ ];
187
+
188
+ for (const { gen } of directions) {
189
+ for (let offset = minGap; ; offset += stepSize) {
190
+ const cand = gen(offset);
191
+ if (!cand) break; // out of bounds in this direction
192
+
193
+ // Check collisions with placed labels
194
+ let collision = false;
195
+ for (const pl of placedLabels) {
196
+ if (rectsOverlap(cand.rect, pl)) {
197
+ collision = true;
198
+ break;
199
+ }
200
+ }
201
+
202
+ // Check collisions with point circles
203
+ if (!collision) {
204
+ for (const circle of pointCircles) {
205
+ if (rectCircleOverlap(cand.rect, circle)) {
206
+ collision = true;
207
+ break;
208
+ }
209
+ }
210
+ }
211
+
212
+ // Check collisions with obstacle rects (quadrant labels)
213
+ if (!collision) {
214
+ for (const obs of obstacles) {
215
+ if (rectsOverlap(cand.rect, obs)) {
216
+ collision = true;
217
+ break;
218
+ }
219
+ }
220
+ }
221
+
222
+ if (!collision) {
223
+ const dist = offset;
224
+ if (!best || dist < best.dist) {
225
+ best = {
226
+ rect: cand.rect,
227
+ textX: cand.textX,
228
+ textY: cand.textY,
229
+ anchor: cand.anchor,
230
+ dist,
231
+ };
232
+ }
233
+ break; // best for this direction found
234
+ }
235
+ }
236
+ }
237
+
238
+ // Fallback: place above at minGap (may overlap, but at least visible)
239
+ if (!best) {
240
+ const lx = pt.cx - labelWidth / 2;
241
+ const ly = pt.cy - minGap - labelHeight;
242
+ best = {
243
+ rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
244
+ textX: pt.cx,
245
+ textY: ly + labelHeight / 2,
246
+ anchor: 'middle',
247
+ dist: minGap,
248
+ };
249
+ }
250
+
251
+ placedLabels.push(best.rect);
252
+
253
+ // Connector line when label is pushed beyond immediate adjacency
254
+ let connectorLine: PlacedQuadrantLabel['connectorLine'];
255
+ if (best.dist > minGap + stepSize) {
256
+ // Determine connector endpoints: from point edge to label edge
257
+ const dx = best.textX - pt.cx;
258
+ const dy = best.textY - pt.cy;
259
+ const angle = Math.atan2(dy, dx);
260
+ const x1 = pt.cx + Math.cos(angle) * pointRadius;
261
+ const y1 = pt.cy + Math.sin(angle) * pointRadius;
262
+
263
+ // Label edge: closest point on label rect to the point
264
+ const x2 = Math.max(
265
+ best.rect.x,
266
+ Math.min(pt.cx, best.rect.x + best.rect.w)
267
+ );
268
+ const y2 = Math.max(
269
+ best.rect.y,
270
+ Math.min(pt.cy, best.rect.y + best.rect.h)
271
+ );
272
+
273
+ connectorLine = { x1, y1, x2, y2 };
274
+ }
275
+
276
+ results.push({
277
+ label: pt.label,
278
+ x: best.textX,
279
+ y: best.textY,
280
+ anchor: best.anchor,
281
+ connectorLine,
282
+ });
283
+ }
284
+
285
+ return results;
286
+ }
@@ -16,20 +16,14 @@ import { parseOrg } from './parser';
16
16
  import { layoutOrg } from './layout';
17
17
  import {
18
18
  LEGEND_HEIGHT,
19
- LEGEND_PILL_PAD,
20
- LEGEND_PILL_FONT_SIZE,
21
- LEGEND_CAPSULE_PAD,
22
- LEGEND_DOT_R,
23
- LEGEND_ENTRY_FONT_SIZE,
24
- LEGEND_ENTRY_DOT_GAP,
25
- LEGEND_ENTRY_TRAIL,
26
19
  LEGEND_GROUP_GAP,
27
20
  LEGEND_EYE_SIZE,
28
21
  LEGEND_EYE_GAP,
29
22
  EYE_OPEN_PATH,
30
23
  EYE_CLOSED_PATH,
31
- measureLegendText,
32
24
  } from '../utils/legend-constants';
25
+ import { renderLegendD3 } from '../utils/legend-d3';
26
+ import type { LegendConfig, LegendState } from '../utils/legend-types';
33
27
 
34
28
  // ============================================================
35
29
  // Constants
@@ -487,33 +481,17 @@ export function renderOrg(
487
481
  }
488
482
  }
489
483
 
490
- // Render legend — kanban-style pills.
484
+ // Render legend — capsule pills.
491
485
  // In app mode (fixedLegend): render at native size outside the scaled group.
492
486
  // In export mode: skip legend (unless legend-only chart).
493
487
  // Legend-only (no nodes): all groups rendered as expanded capsules inside scaled group.
494
488
  if (fixedLegend || legendOnly || (exportDims && hasLegend)) {
495
- // Determine which groups to render
496
- const visibleGroups = layout.legend.filter((group) => {
497
- if (legendOnly) return true;
498
- if (activeTagGroup == null) return true;
499
- return group.name.toLowerCase() === activeTagGroup.toLowerCase();
500
- });
489
+ const groups = layout.legend.map((g) => ({
490
+ name: g.name,
491
+ entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
492
+ }));
501
493
 
502
- // For fixedLegend: compute positions in pixel space, centered in SVG
503
- let fixedPositions: Map<string, number> | undefined;
504
- if (fixedLegend && visibleGroups.length > 0) {
505
- fixedPositions = new Map();
506
- const effectiveW = (g: (typeof visibleGroups)[0]) =>
507
- activeTagGroup != null ? g.width : g.minifiedWidth;
508
- const totalW =
509
- visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
510
- (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
511
- let cx = (width - totalW) / 2;
512
- for (const g of visibleGroups) {
513
- fixedPositions.set(g.name, cx);
514
- cx += effectiveW(g) + LEGEND_GROUP_GAP;
515
- }
516
- }
494
+ const eyeAddonWidth = fixedLegend ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
517
495
 
518
496
  // Choose parent: unscaled group for fixedLegend, contentG for legend-only
519
497
  const legendParentBase = fixedLegend
@@ -521,152 +499,107 @@ export function renderOrg(
521
499
  .append('g')
522
500
  .attr('class', 'org-legend-fixed')
523
501
  .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleReserve})`)
524
- : contentG;
525
- const legendParent = legendParentBase;
526
- if (fixedLegend && activeTagGroup) {
527
- legendParentBase.attr('data-legend-active', activeTagGroup.toLowerCase());
528
- }
529
-
530
- for (const group of visibleGroups) {
531
- const isActive =
532
- legendOnly ||
533
- (activeTagGroup != null &&
534
- group.name.toLowerCase() === activeTagGroup.toLowerCase());
535
-
536
- const groupBg = isDark
537
- ? mix(palette.surface, palette.bg, 50)
538
- : mix(palette.surface, palette.bg, 30);
539
-
540
- const pillLabel = group.name;
541
- const pillWidth =
542
- measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
543
-
544
- const gX = fixedPositions?.get(group.name) ?? group.x;
545
- const gY = fixedPositions ? 0 : group.y;
546
-
547
- const gEl = legendParent
548
- .append('g')
549
- .attr('transform', `translate(${gX}, ${gY})`)
550
- .attr('class', 'org-legend-group')
551
- .attr('data-legend-group', group.name.toLowerCase())
552
- .style('cursor', legendOnly ? 'default' : 'pointer');
553
-
554
- // Outer capsule background (active only)
555
- if (isActive) {
556
- gEl
557
- .append('rect')
558
- .attr('width', group.width)
559
- .attr('height', LEGEND_HEIGHT)
560
- .attr('rx', LEGEND_HEIGHT / 2)
561
- .attr('fill', groupBg);
562
- }
563
-
564
- const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
565
- const pillYOff = LEGEND_CAPSULE_PAD;
566
- const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
567
-
568
- // Pill background
569
- gEl
570
- .append('rect')
571
- .attr('x', pillXOff)
572
- .attr('y', pillYOff)
573
- .attr('width', pillWidth)
574
- .attr('height', pillH)
575
- .attr('rx', pillH / 2)
576
- .attr('fill', isActive ? palette.bg : groupBg);
577
-
578
- // Active pill border
579
- if (isActive) {
580
- gEl
581
- .append('rect')
582
- .attr('x', pillXOff)
583
- .attr('y', pillYOff)
584
- .attr('width', pillWidth)
585
- .attr('height', pillH)
586
- .attr('rx', pillH / 2)
587
- .attr('fill', 'none')
588
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
589
- .attr('stroke-width', 0.75);
502
+ : contentG.append('g');
503
+
504
+ let legendHandle;
505
+ if (legendOnly) {
506
+ // Legend-only mode: render each group expanded individually at layout positions
507
+ for (const lg of layout.legend) {
508
+ const singleConfig: LegendConfig = {
509
+ groups: [
510
+ {
511
+ name: lg.name,
512
+ entries: lg.entries.map((e) => ({
513
+ value: e.value,
514
+ color: e.color,
515
+ })),
516
+ },
517
+ ],
518
+ position: { placement: 'top-center', titleRelation: 'below-title' },
519
+ mode: 'fixed',
520
+ };
521
+ const singleState: LegendState = { activeGroup: lg.name };
522
+ const groupG = legendParentBase
523
+ .append('g')
524
+ .attr('transform', `translate(${lg.x}, ${lg.y})`);
525
+ renderLegendD3(
526
+ groupG,
527
+ singleConfig,
528
+ singleState,
529
+ palette,
530
+ isDark,
531
+ undefined,
532
+ lg.width
533
+ );
534
+ groupG
535
+ .selectAll('[data-legend-group]')
536
+ .classed('org-legend-group', true);
590
537
  }
538
+ legendHandle = null;
539
+ } else {
540
+ const legendConfig: LegendConfig = {
541
+ groups,
542
+ position: { placement: 'top-center', titleRelation: 'below-title' },
543
+ mode: 'fixed',
544
+ capsulePillAddonWidth: eyeAddonWidth,
545
+ };
546
+ const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
547
+ legendHandle = renderLegendD3(
548
+ legendParentBase,
549
+ legendConfig,
550
+ legendState,
551
+ palette,
552
+ isDark,
553
+ undefined,
554
+ fixedLegend ? width : layout.width
555
+ );
556
+ legendParentBase
557
+ .selectAll('[data-legend-group]')
558
+ .classed('org-legend-group', true);
559
+ }
591
560
 
592
- // Pill text
593
- gEl
594
- .append('text')
595
- .attr('x', pillXOff + pillWidth / 2)
596
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
597
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
598
- .attr('font-weight', '500')
599
- .attr('fill', isActive ? palette.text : palette.textMuted)
600
- .attr('text-anchor', 'middle')
601
- .text(pillLabel);
602
-
603
- // Eye icon for visibility toggle (active only, app mode)
604
- if (isActive && fixedLegend) {
605
- const groupKey = group.name.toLowerCase();
561
+ // Inject eye icons into active group capsules (app mode only)
562
+ if (fixedLegend && legendHandle) {
563
+ const computedLayout = legendHandle.getLayout();
564
+ if (computedLayout.activeCapsule?.addonX != null) {
565
+ const capsule = computedLayout.activeCapsule;
566
+ const groupKey = capsule.groupName.toLowerCase();
606
567
  const isHidden = hiddenAttributes?.has(groupKey) ?? false;
607
- const eyeX = pillXOff + pillWidth + LEGEND_EYE_GAP;
608
- const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
609
- const hitPad = 6;
610
568
 
611
- const eyeG = gEl
612
- .append('g')
613
- .attr('class', 'org-legend-eye')
614
- .attr('data-legend-visibility', groupKey)
615
- .style('cursor', 'pointer')
616
- .attr('opacity', isHidden ? 0.4 : 0.7);
617
-
618
- // Transparent hit area for easier clicking
619
- eyeG
620
- .append('rect')
621
- .attr('x', eyeX - hitPad)
622
- .attr('y', eyeY - hitPad)
623
- .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
624
- .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
625
- .attr('fill', 'transparent')
626
- .attr('pointer-events', 'all');
627
-
628
- eyeG
629
- .append('path')
630
- .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
631
- .attr('transform', `translate(${eyeX}, ${eyeY})`)
632
- .attr('fill', 'none')
633
- .attr('stroke', palette.textMuted)
634
- .attr('stroke-width', 1.2)
635
- .attr('stroke-linecap', 'round')
636
- .attr('stroke-linejoin', 'round');
637
- }
569
+ // Find the rendered active group <g> and append eye icon
570
+ const activeGroupEl = legendParentBase.select(
571
+ `[data-legend-group="${groupKey}"]`
572
+ );
573
+ if (!activeGroupEl.empty()) {
574
+ const eyeX = capsule.addonX!;
575
+ const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
576
+ const hitPad = 6;
638
577
 
639
- // Entries inside capsule (active only)
640
- if (isActive) {
641
- const eyeShift = fixedLegend ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
642
- let entryX = pillXOff + pillWidth + 4 + eyeShift;
643
- for (const entry of group.entries) {
644
- const entryG = gEl
578
+ const eyeG = activeGroupEl
645
579
  .append('g')
646
- .attr('data-legend-entry', entry.value.toLowerCase())
647
- .style('cursor', 'pointer');
648
-
649
- entryG
650
- .append('circle')
651
- .attr('cx', entryX + LEGEND_DOT_R)
652
- .attr('cy', LEGEND_HEIGHT / 2)
653
- .attr('r', LEGEND_DOT_R)
654
- .attr('fill', entry.color);
655
-
656
- const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
657
- const entryLabel = entry.value;
658
- entryG
659
- .append('text')
660
- .attr('x', textX)
661
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
662
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
663
- .attr('fill', palette.textMuted)
664
- .text(entryLabel);
665
-
666
- entryX =
667
- textX +
668
- measureLegendText(entryLabel, LEGEND_ENTRY_FONT_SIZE) +
669
- LEGEND_ENTRY_TRAIL;
580
+ .attr('class', 'org-legend-eye')
581
+ .attr('data-legend-visibility', groupKey)
582
+ .style('cursor', 'pointer')
583
+ .attr('opacity', isHidden ? 0.4 : 0.7);
584
+
585
+ eyeG
586
+ .append('rect')
587
+ .attr('x', eyeX - hitPad)
588
+ .attr('y', eyeY - hitPad)
589
+ .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
590
+ .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
591
+ .attr('fill', 'transparent')
592
+ .attr('pointer-events', 'all');
593
+
594
+ eyeG
595
+ .append('path')
596
+ .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
597
+ .attr('transform', `translate(${eyeX}, ${eyeY})`)
598
+ .attr('fill', 'none')
599
+ .attr('stroke', palette.textMuted)
600
+ .attr('stroke-width', 1.2)
601
+ .attr('stroke-linecap', 'round')
602
+ .attr('stroke-linejoin', 'round');
670
603
  }
671
604
  }
672
605
  }
package/src/render.ts CHANGED
@@ -15,11 +15,26 @@ async function ensureDom(): Promise<void> {
15
15
  const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
16
16
  const win = dom.window;
17
17
 
18
- Object.defineProperty(globalThis, 'document', { value: win.document, configurable: true });
19
- Object.defineProperty(globalThis, 'window', { value: win, configurable: true });
20
- Object.defineProperty(globalThis, 'navigator', { value: win.navigator, configurable: true });
21
- Object.defineProperty(globalThis, 'HTMLElement', { value: win.HTMLElement, configurable: true });
22
- Object.defineProperty(globalThis, 'SVGElement', { value: win.SVGElement, configurable: true });
18
+ Object.defineProperty(globalThis, 'document', {
19
+ value: win.document,
20
+ configurable: true,
21
+ });
22
+ Object.defineProperty(globalThis, 'window', {
23
+ value: win,
24
+ configurable: true,
25
+ });
26
+ Object.defineProperty(globalThis, 'navigator', {
27
+ value: win.navigator,
28
+ configurable: true,
29
+ });
30
+ Object.defineProperty(globalThis, 'HTMLElement', {
31
+ value: win.HTMLElement,
32
+ configurable: true,
33
+ });
34
+ Object.defineProperty(globalThis, 'SVGElement', {
35
+ value: win.SVGElement,
36
+ configurable: true,
37
+ });
23
38
  }
24
39
 
25
40
  /**
@@ -52,24 +67,39 @@ export async function render(
52
67
  c4System?: string;
53
68
  c4Container?: string;
54
69
  tagGroup?: string;
55
- },
70
+ /** Legend state for export — controls which tag group is shown in exported SVG. */
71
+ legendState?: { activeGroup?: string; hiddenAttributes?: string[] };
72
+ }
56
73
  ): Promise<string> {
57
74
  const theme = options?.theme ?? 'light';
58
75
  const paletteName = options?.palette ?? 'nord';
59
76
  const branding = options?.branding ?? false;
60
77
 
61
- const paletteColors = getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
78
+ const paletteColors =
79
+ getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
62
80
 
63
81
  const chartType = parseDgmoChartType(content);
64
82
  const category = chartType ? getRenderCategory(chartType) : null;
65
83
 
84
+ // Build orgExportState from legendState if provided
85
+ const legendExportState = options?.legendState
86
+ ? {
87
+ activeTagGroup: options.legendState.activeGroup ?? null,
88
+ hiddenAttributes: options.legendState.hiddenAttributes
89
+ ? new Set(options.legendState.hiddenAttributes)
90
+ : undefined,
91
+ }
92
+ : undefined;
93
+
66
94
  if (category === 'data-chart') {
67
- return renderExtendedChartForExport(content, theme, paletteColors, { branding });
95
+ return renderExtendedChartForExport(content, theme, paletteColors, {
96
+ branding,
97
+ });
68
98
  }
69
99
 
70
100
  // Visualization/diagram and unknown/null types all go through the unified renderer
71
101
  await ensureDom();
72
- return renderForExport(content, theme, paletteColors, undefined, {
102
+ return renderForExport(content, theme, paletteColors, legendExportState, {
73
103
  branding,
74
104
  c4Level: options?.c4Level,
75
105
  c4System: options?.c4System,