@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.
- package/AGENTS.md +3 -0
- package/dist/cli.cjs +245 -672
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.d.cts +2 -3
- package/dist/editor.d.ts +2 -3
- package/dist/editor.js.map +1 -1
- package/dist/index.cjs +1623 -800
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +153 -1
- package/dist/index.d.ts +153 -1
- package/dist/index.js +1619 -802
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +28 -2
- package/gallery/fixtures/sitemap-full.dgmo +1 -0
- package/package.json +14 -17
- package/src/boxes-and-lines/layout.ts +48 -8
- package/src/boxes-and-lines/parser.ts +59 -13
- package/src/boxes-and-lines/renderer.ts +34 -138
- package/src/c4/layout.ts +31 -10
- package/src/c4/renderer.ts +25 -138
- package/src/class/renderer.ts +185 -186
- package/src/d3.ts +194 -222
- package/src/echarts.ts +56 -57
- package/src/editor/index.ts +1 -2
- package/src/er/renderer.ts +52 -245
- package/src/gantt/renderer.ts +140 -182
- package/src/gantt/resolver.ts +19 -14
- package/src/index.ts +23 -1
- package/src/infra/renderer.ts +91 -244
- package/src/kanban/renderer.ts +29 -133
- package/src/label-layout.ts +286 -0
- package/src/org/renderer.ts +103 -170
- package/src/render.ts +39 -9
- package/src/sequence/parser.ts +4 -0
- package/src/sequence/renderer.ts +47 -154
- package/src/sitemap/layout.ts +180 -38
- package/src/sitemap/parser.ts +64 -23
- package/src/sitemap/renderer.ts +73 -161
- package/src/utils/arrows.ts +1 -1
- package/src/utils/legend-constants.ts +6 -0
- package/src/utils/legend-d3.ts +400 -0
- package/src/utils/legend-layout.ts +491 -0
- package/src/utils/legend-svg.ts +28 -2
- package/src/utils/legend-types.ts +166 -0
- package/src/utils/parsing.ts +1 -1
- 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
|
+
}
|
package/src/org/renderer.ts
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
.
|
|
558
|
-
.
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
.
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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('
|
|
647
|
-
.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
.
|
|
653
|
-
.attr('
|
|
654
|
-
.attr('
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
.
|
|
662
|
-
.attr('
|
|
663
|
-
.attr('
|
|
664
|
-
.
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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', {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Object.defineProperty(globalThis, '
|
|
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 =
|
|
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, {
|
|
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,
|
|
102
|
+
return renderForExport(content, theme, paletteColors, legendExportState, {
|
|
73
103
|
branding,
|
|
74
104
|
c4Level: options?.c4Level,
|
|
75
105
|
c4System: options?.c4System,
|