@diagrammo/dgmo 0.8.8 → 0.8.10
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 +181 -179
- package/dist/index.cjs +1425 -933
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +147 -1
- package/dist/index.d.ts +147 -1
- package/dist/index.js +1421 -933
- 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 +1 -1
- 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 +33 -137
- package/src/c4/renderer.ts +25 -138
- package/src/class/renderer.ts +185 -186
- package/src/d3.ts +114 -191
- package/src/echarts.ts +99 -214
- package/src/er/renderer.ts +52 -245
- package/src/gantt/renderer.ts +140 -182
- package/src/index.ts +21 -1
- package/src/infra/renderer.ts +91 -244
- package/src/kanban/renderer.ts +22 -129
- package/src/org/renderer.ts +103 -170
- package/src/render.ts +39 -9
- package/src/sequence/renderer.ts +31 -151
- package/src/sitemap/layout.ts +180 -38
- package/src/sitemap/parser.ts +64 -23
- package/src/sitemap/renderer.ts +73 -161
- package/src/utils/legend-constants.ts +6 -0
- package/src/utils/legend-d3.ts +400 -0
- package/src/utils/legend-layout.ts +495 -0
- package/src/utils/legend-svg.ts +26 -0
- package/src/utils/legend-types.ts +169 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Centralized legend layout engine
|
|
3
|
+
// Pure function: config + state + width → positions
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
LEGEND_HEIGHT,
|
|
8
|
+
LEGEND_PILL_PAD,
|
|
9
|
+
LEGEND_PILL_FONT_SIZE,
|
|
10
|
+
LEGEND_CAPSULE_PAD,
|
|
11
|
+
LEGEND_DOT_R,
|
|
12
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
13
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
14
|
+
LEGEND_ENTRY_TRAIL,
|
|
15
|
+
LEGEND_GROUP_GAP,
|
|
16
|
+
measureLegendText,
|
|
17
|
+
} from './legend-constants';
|
|
18
|
+
|
|
19
|
+
import type { LegendGroupData } from './legend-svg';
|
|
20
|
+
import type {
|
|
21
|
+
LegendConfig,
|
|
22
|
+
LegendState,
|
|
23
|
+
LegendLayout,
|
|
24
|
+
LegendPillLayout,
|
|
25
|
+
LegendCapsuleLayout,
|
|
26
|
+
LegendControlLayout,
|
|
27
|
+
LegendEntryLayout,
|
|
28
|
+
LegendControl,
|
|
29
|
+
} from './legend-types';
|
|
30
|
+
|
|
31
|
+
// ── Constants ───────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const LEGEND_TOP_PAD = 12;
|
|
34
|
+
export const LEGEND_TITLE_GAP = 8;
|
|
35
|
+
export const LEGEND_CONTENT_GAP = 12;
|
|
36
|
+
export const LEGEND_MAX_ENTRY_ROWS = 3;
|
|
37
|
+
|
|
38
|
+
const CONTROL_PILL_PAD = 16;
|
|
39
|
+
const CONTROL_FONT_SIZE = 11;
|
|
40
|
+
const CONTROL_ICON_GAP = 4;
|
|
41
|
+
const CONTROL_GAP = 8;
|
|
42
|
+
|
|
43
|
+
// ── Measurement helpers ─────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function pillWidth(name: string): number {
|
|
46
|
+
return measureLegendText(name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function entriesWidth(entries: Array<{ value: string }>): number {
|
|
50
|
+
let w = 0;
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
w +=
|
|
53
|
+
LEGEND_DOT_R * 2 +
|
|
54
|
+
LEGEND_ENTRY_DOT_GAP +
|
|
55
|
+
measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
56
|
+
LEGEND_ENTRY_TRAIL;
|
|
57
|
+
}
|
|
58
|
+
return w;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function entryWidth(value: string): number {
|
|
62
|
+
return (
|
|
63
|
+
LEGEND_DOT_R * 2 +
|
|
64
|
+
LEGEND_ENTRY_DOT_GAP +
|
|
65
|
+
measureLegendText(value, LEGEND_ENTRY_FONT_SIZE) +
|
|
66
|
+
LEGEND_ENTRY_TRAIL
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function controlWidth(control: LegendControl): number {
|
|
71
|
+
let w = CONTROL_PILL_PAD;
|
|
72
|
+
if (control.label) {
|
|
73
|
+
w += measureLegendText(control.label, CONTROL_FONT_SIZE);
|
|
74
|
+
if (control.icon) w += CONTROL_ICON_GAP;
|
|
75
|
+
}
|
|
76
|
+
// Icon space (approximate — icons are typically ~14px)
|
|
77
|
+
if (control.icon) w += 14;
|
|
78
|
+
if (control.children) {
|
|
79
|
+
for (const child of control.children) {
|
|
80
|
+
w += measureLegendText(child.label, CONTROL_FONT_SIZE) + 12;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return w;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function capsuleWidth(
|
|
87
|
+
name: string,
|
|
88
|
+
entries: Array<{ value: string }>,
|
|
89
|
+
containerWidth: number,
|
|
90
|
+
addonWidth = 0
|
|
91
|
+
): {
|
|
92
|
+
width: number;
|
|
93
|
+
entryRows: number;
|
|
94
|
+
moreCount: number;
|
|
95
|
+
visibleEntries: number;
|
|
96
|
+
} {
|
|
97
|
+
const pw = pillWidth(name);
|
|
98
|
+
const maxCapsuleW = containerWidth;
|
|
99
|
+
const baseW = LEGEND_CAPSULE_PAD * 2 + pw + 4 + addonWidth;
|
|
100
|
+
|
|
101
|
+
// Try single row first
|
|
102
|
+
const ew = entriesWidth(entries);
|
|
103
|
+
const singleRowW = baseW + ew;
|
|
104
|
+
if (singleRowW <= maxCapsuleW) {
|
|
105
|
+
return {
|
|
106
|
+
width: singleRowW,
|
|
107
|
+
entryRows: 1,
|
|
108
|
+
moreCount: 0,
|
|
109
|
+
visibleEntries: entries.length,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Multi-row: compute how many entries fit per row
|
|
114
|
+
const rowWidth = maxCapsuleW - LEGEND_CAPSULE_PAD * 2;
|
|
115
|
+
let row = 1;
|
|
116
|
+
let rowX = pw + 4;
|
|
117
|
+
let visible = 0;
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < entries.length; i++) {
|
|
120
|
+
const ew2 = entryWidth(entries[i].value);
|
|
121
|
+
if (rowX + ew2 > rowWidth && rowX > pw + 4) {
|
|
122
|
+
row++;
|
|
123
|
+
rowX = 0;
|
|
124
|
+
if (row > LEGEND_MAX_ENTRY_ROWS) {
|
|
125
|
+
return {
|
|
126
|
+
width: maxCapsuleW,
|
|
127
|
+
entryRows: LEGEND_MAX_ENTRY_ROWS,
|
|
128
|
+
moreCount: entries.length - visible,
|
|
129
|
+
visibleEntries: visible,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
rowX += ew2;
|
|
134
|
+
visible++;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
width: maxCapsuleW,
|
|
139
|
+
entryRows: row,
|
|
140
|
+
moreCount: 0,
|
|
141
|
+
visibleEntries: entries.length,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Main layout computation ─────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export function computeLegendLayout(
|
|
148
|
+
config: LegendConfig,
|
|
149
|
+
state: LegendState,
|
|
150
|
+
containerWidth: number
|
|
151
|
+
): LegendLayout {
|
|
152
|
+
const { groups, controls: configControls, mode } = config;
|
|
153
|
+
const isExport = mode === 'inline';
|
|
154
|
+
|
|
155
|
+
// Filter groups for export: only active group shown
|
|
156
|
+
const activeGroupName = state.activeGroup?.toLowerCase() ?? null;
|
|
157
|
+
|
|
158
|
+
// In export mode with no active group, no legend
|
|
159
|
+
if (isExport && !activeGroupName) {
|
|
160
|
+
return {
|
|
161
|
+
height: 0,
|
|
162
|
+
width: 0,
|
|
163
|
+
rows: [],
|
|
164
|
+
controls: [],
|
|
165
|
+
pills: [],
|
|
166
|
+
activeCapsule: undefined,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const visibleGroups = config.showEmptyGroups
|
|
171
|
+
? groups
|
|
172
|
+
: groups.filter((g) => g.entries.length > 0);
|
|
173
|
+
if (
|
|
174
|
+
visibleGroups.length === 0 &&
|
|
175
|
+
(!configControls || configControls.length === 0)
|
|
176
|
+
) {
|
|
177
|
+
return {
|
|
178
|
+
height: 0,
|
|
179
|
+
width: 0,
|
|
180
|
+
rows: [],
|
|
181
|
+
controls: [],
|
|
182
|
+
pills: [],
|
|
183
|
+
activeCapsule: undefined,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Compute control layouts (right-aligned)
|
|
188
|
+
const controlLayouts: LegendControlLayout[] = [];
|
|
189
|
+
let totalControlsW = 0;
|
|
190
|
+
|
|
191
|
+
if (configControls && !isExport) {
|
|
192
|
+
for (const ctrl of configControls) {
|
|
193
|
+
const w = controlWidth(ctrl);
|
|
194
|
+
controlLayouts.push({
|
|
195
|
+
id: ctrl.id,
|
|
196
|
+
x: 0, // positioned later
|
|
197
|
+
y: 0,
|
|
198
|
+
width: w,
|
|
199
|
+
height: LEGEND_HEIGHT,
|
|
200
|
+
icon: ctrl.icon,
|
|
201
|
+
label: ctrl.label,
|
|
202
|
+
exportBehavior: ctrl.exportBehavior,
|
|
203
|
+
children: ctrl.children?.map((c) => ({
|
|
204
|
+
id: c.id,
|
|
205
|
+
label: c.label,
|
|
206
|
+
x: 0,
|
|
207
|
+
y: 0,
|
|
208
|
+
width: measureLegendText(c.label, CONTROL_FONT_SIZE) + 12,
|
|
209
|
+
isActive: c.isActive,
|
|
210
|
+
})),
|
|
211
|
+
});
|
|
212
|
+
totalControlsW += w + CONTROL_GAP;
|
|
213
|
+
}
|
|
214
|
+
if (totalControlsW > 0) totalControlsW -= CONTROL_GAP; // remove trailing gap
|
|
215
|
+
} else if (configControls && isExport) {
|
|
216
|
+
// In export, include controls with exportBehavior 'include' or 'static'
|
|
217
|
+
for (const ctrl of configControls) {
|
|
218
|
+
if (ctrl.exportBehavior === 'strip') continue;
|
|
219
|
+
const w = controlWidth(ctrl);
|
|
220
|
+
controlLayouts.push({
|
|
221
|
+
id: ctrl.id,
|
|
222
|
+
x: 0,
|
|
223
|
+
y: 0,
|
|
224
|
+
width: w,
|
|
225
|
+
height: LEGEND_HEIGHT,
|
|
226
|
+
icon: ctrl.icon,
|
|
227
|
+
label: ctrl.label,
|
|
228
|
+
exportBehavior: ctrl.exportBehavior,
|
|
229
|
+
children: ctrl.children?.map((c) => ({
|
|
230
|
+
id: c.id,
|
|
231
|
+
label: c.label,
|
|
232
|
+
x: 0,
|
|
233
|
+
y: 0,
|
|
234
|
+
width: measureLegendText(c.label, CONTROL_FONT_SIZE) + 12,
|
|
235
|
+
isActive: c.isActive,
|
|
236
|
+
})),
|
|
237
|
+
});
|
|
238
|
+
totalControlsW += w + CONTROL_GAP;
|
|
239
|
+
}
|
|
240
|
+
if (totalControlsW > 0) totalControlsW -= CONTROL_GAP;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Available width for tag groups (controls anchor right)
|
|
244
|
+
const controlsSpace =
|
|
245
|
+
totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0;
|
|
246
|
+
const groupAvailW = containerWidth - controlsSpace;
|
|
247
|
+
|
|
248
|
+
// Build pill/capsule layouts
|
|
249
|
+
const pills: LegendPillLayout[] = [];
|
|
250
|
+
let activeCapsule: LegendCapsuleLayout | undefined;
|
|
251
|
+
|
|
252
|
+
for (const g of visibleGroups) {
|
|
253
|
+
const isActive = activeGroupName === g.name.toLowerCase();
|
|
254
|
+
|
|
255
|
+
// In export mode, skip non-active groups
|
|
256
|
+
if (isExport && !isActive) continue;
|
|
257
|
+
|
|
258
|
+
if (isActive) {
|
|
259
|
+
activeCapsule = buildCapsuleLayout(
|
|
260
|
+
g,
|
|
261
|
+
containerWidth,
|
|
262
|
+
config.capsulePillAddonWidth ?? 0
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
265
|
+
const pw = pillWidth(g.name);
|
|
266
|
+
pills.push({
|
|
267
|
+
groupName: g.name,
|
|
268
|
+
x: 0,
|
|
269
|
+
y: 0,
|
|
270
|
+
width: pw,
|
|
271
|
+
height: LEGEND_HEIGHT,
|
|
272
|
+
isActive: false,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Position elements in rows
|
|
278
|
+
const rows = layoutRows(
|
|
279
|
+
activeCapsule,
|
|
280
|
+
pills,
|
|
281
|
+
controlLayouts,
|
|
282
|
+
groupAvailW,
|
|
283
|
+
containerWidth,
|
|
284
|
+
totalControlsW
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const height = rows.length * LEGEND_HEIGHT;
|
|
288
|
+
const width = containerWidth;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
height,
|
|
292
|
+
width,
|
|
293
|
+
rows,
|
|
294
|
+
activeCapsule,
|
|
295
|
+
controls: controlLayouts,
|
|
296
|
+
pills,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Build capsule for active group ──────────────────────────
|
|
301
|
+
|
|
302
|
+
function buildCapsuleLayout(
|
|
303
|
+
group: LegendGroupData,
|
|
304
|
+
containerWidth: number,
|
|
305
|
+
addonWidth = 0
|
|
306
|
+
): LegendCapsuleLayout {
|
|
307
|
+
const pw = pillWidth(group.name);
|
|
308
|
+
const info = capsuleWidth(
|
|
309
|
+
group.name,
|
|
310
|
+
group.entries,
|
|
311
|
+
containerWidth,
|
|
312
|
+
addonWidth
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const pill: LegendPillLayout = {
|
|
316
|
+
groupName: group.name,
|
|
317
|
+
x: LEGEND_CAPSULE_PAD,
|
|
318
|
+
y: LEGEND_CAPSULE_PAD,
|
|
319
|
+
width: pw,
|
|
320
|
+
height: LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2,
|
|
321
|
+
isActive: true,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const entries: LegendEntryLayout[] = [];
|
|
325
|
+
let ex = LEGEND_CAPSULE_PAD + pw + 4 + addonWidth;
|
|
326
|
+
let ey = 0;
|
|
327
|
+
let rowX = ex;
|
|
328
|
+
const maxRowW = containerWidth - LEGEND_CAPSULE_PAD * 2;
|
|
329
|
+
let currentRow = 0;
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < info.visibleEntries; i++) {
|
|
332
|
+
const entry = group.entries[i];
|
|
333
|
+
const ew = entryWidth(entry.value);
|
|
334
|
+
|
|
335
|
+
// Wrap to next row if needed
|
|
336
|
+
if (rowX + ew > maxRowW && rowX > ex && i > 0) {
|
|
337
|
+
currentRow++;
|
|
338
|
+
rowX = 0;
|
|
339
|
+
ey = currentRow * LEGEND_HEIGHT;
|
|
340
|
+
if (currentRow === 0) ex = LEGEND_CAPSULE_PAD + pw + 4;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const dotCx = rowX + LEGEND_DOT_R;
|
|
344
|
+
const dotCy = ey + LEGEND_HEIGHT / 2;
|
|
345
|
+
const textX = rowX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
|
|
346
|
+
const textY = ey + LEGEND_HEIGHT / 2;
|
|
347
|
+
|
|
348
|
+
entries.push({
|
|
349
|
+
value: entry.value,
|
|
350
|
+
color: entry.color,
|
|
351
|
+
x: rowX,
|
|
352
|
+
y: ey,
|
|
353
|
+
dotCx,
|
|
354
|
+
dotCy,
|
|
355
|
+
textX,
|
|
356
|
+
textY,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
rowX += ew;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const totalRows = info.entryRows;
|
|
363
|
+
const capsuleH = totalRows * LEGEND_HEIGHT;
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
groupName: group.name,
|
|
367
|
+
x: 0,
|
|
368
|
+
y: 0,
|
|
369
|
+
width: info.width,
|
|
370
|
+
height: capsuleH,
|
|
371
|
+
pill,
|
|
372
|
+
entries,
|
|
373
|
+
moreCount: info.moreCount > 0 ? info.moreCount : undefined,
|
|
374
|
+
addonX: addonWidth > 0 ? LEGEND_CAPSULE_PAD + pw + 4 : undefined,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Row layout ──────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
function layoutRows(
|
|
381
|
+
activeCapsule: LegendCapsuleLayout | undefined,
|
|
382
|
+
pills: LegendPillLayout[],
|
|
383
|
+
controls: LegendControlLayout[],
|
|
384
|
+
groupAvailW: number,
|
|
385
|
+
containerWidth: number,
|
|
386
|
+
totalControlsW: number
|
|
387
|
+
): Array<{
|
|
388
|
+
y: number;
|
|
389
|
+
items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
|
|
390
|
+
}> {
|
|
391
|
+
const rows: Array<{
|
|
392
|
+
y: number;
|
|
393
|
+
items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
|
|
394
|
+
}> = [];
|
|
395
|
+
|
|
396
|
+
// Collect all group-side items in display order: active capsule first, then collapsed pills
|
|
397
|
+
const groupItems: Array<LegendPillLayout | LegendCapsuleLayout> = [];
|
|
398
|
+
if (activeCapsule) groupItems.push(activeCapsule);
|
|
399
|
+
groupItems.push(...pills);
|
|
400
|
+
|
|
401
|
+
// Compute total group items width
|
|
402
|
+
let currentRowItems: Array<
|
|
403
|
+
LegendPillLayout | LegendCapsuleLayout | LegendControlLayout
|
|
404
|
+
> = [];
|
|
405
|
+
let currentRowW = 0;
|
|
406
|
+
let rowY = 0;
|
|
407
|
+
|
|
408
|
+
for (const item of groupItems) {
|
|
409
|
+
const itemW = item.width + LEGEND_GROUP_GAP;
|
|
410
|
+
if (currentRowW + item.width > groupAvailW && currentRowItems.length > 0) {
|
|
411
|
+
// Commit current row
|
|
412
|
+
centerRowItems(currentRowItems, containerWidth, totalControlsW);
|
|
413
|
+
rows.push({ y: rowY, items: currentRowItems });
|
|
414
|
+
rowY += LEGEND_HEIGHT;
|
|
415
|
+
currentRowItems = [];
|
|
416
|
+
currentRowW = 0;
|
|
417
|
+
}
|
|
418
|
+
item.x = currentRowW;
|
|
419
|
+
item.y = rowY;
|
|
420
|
+
currentRowItems.push(item);
|
|
421
|
+
currentRowW += itemW;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Add controls to first row (right-aligned)
|
|
425
|
+
if (controls.length > 0) {
|
|
426
|
+
let cx = containerWidth;
|
|
427
|
+
for (let i = controls.length - 1; i >= 0; i--) {
|
|
428
|
+
cx -= controls[i].width;
|
|
429
|
+
controls[i].x = cx;
|
|
430
|
+
controls[i].y = 0;
|
|
431
|
+
cx -= CONTROL_GAP;
|
|
432
|
+
}
|
|
433
|
+
// Controls go on first row
|
|
434
|
+
if (rows.length > 0) {
|
|
435
|
+
rows[0].items.push(...controls);
|
|
436
|
+
} else if (currentRowItems.length > 0) {
|
|
437
|
+
currentRowItems.push(...controls);
|
|
438
|
+
} else {
|
|
439
|
+
// Only controls, no groups
|
|
440
|
+
currentRowItems.push(...controls);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Commit last row
|
|
445
|
+
if (currentRowItems.length > 0) {
|
|
446
|
+
centerRowItems(currentRowItems, containerWidth, totalControlsW);
|
|
447
|
+
rows.push({ y: rowY, items: currentRowItems });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Ensure at least one row height
|
|
451
|
+
if (rows.length === 0) {
|
|
452
|
+
rows.push({ y: 0, items: [] });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return rows;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function centerRowItems(
|
|
459
|
+
items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>,
|
|
460
|
+
containerWidth: number,
|
|
461
|
+
totalControlsW: number
|
|
462
|
+
): void {
|
|
463
|
+
// Only center group items (pills and capsules), not controls
|
|
464
|
+
const groupItems = items.filter((it) => 'groupName' in it) as Array<
|
|
465
|
+
LegendPillLayout | LegendCapsuleLayout
|
|
466
|
+
>;
|
|
467
|
+
|
|
468
|
+
if (groupItems.length === 0) return;
|
|
469
|
+
|
|
470
|
+
const totalGroupW =
|
|
471
|
+
groupItems.reduce((s, it) => s + it.width, 0) +
|
|
472
|
+
(groupItems.length - 1) * LEGEND_GROUP_GAP;
|
|
473
|
+
|
|
474
|
+
const availW =
|
|
475
|
+
containerWidth -
|
|
476
|
+
(totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0);
|
|
477
|
+
const offset = Math.max(0, (availW - totalGroupW) / 2);
|
|
478
|
+
|
|
479
|
+
let x = offset;
|
|
480
|
+
for (const item of groupItems) {
|
|
481
|
+
item.x = x;
|
|
482
|
+
x += item.width + LEGEND_GROUP_GAP;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Public: height reservation ──────────────────────────────
|
|
487
|
+
|
|
488
|
+
export function getLegendReservedHeight(
|
|
489
|
+
config: LegendConfig,
|
|
490
|
+
state: LegendState,
|
|
491
|
+
containerWidth: number
|
|
492
|
+
): number {
|
|
493
|
+
const layout = computeLegendLayout(config, state, containerWidth);
|
|
494
|
+
return layout.height;
|
|
495
|
+
}
|
package/src/utils/legend-svg.ts
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
// Shared legend SVG string generator
|
|
3
3
|
// Produces SVG <g> elements matching the standard legend style
|
|
4
4
|
// used across all diagram types (capsule pills with colored dots).
|
|
5
|
+
//
|
|
6
|
+
// New config-based API: renderLegendSvgFromConfig()
|
|
7
|
+
// Legacy API: renderLegendSvg() — unchanged, used by ECharts
|
|
5
8
|
// ============================================================
|
|
6
9
|
|
|
10
|
+
import type { LegendConfig, LegendState, LegendPalette } from './legend-types';
|
|
7
11
|
import {
|
|
8
12
|
LEGEND_HEIGHT,
|
|
9
13
|
LEGEND_PILL_PAD,
|
|
@@ -189,3 +193,25 @@ export function renderLegendSvg(
|
|
|
189
193
|
|
|
190
194
|
return { svg, height: LEGEND_HEIGHT, width: totalWidth };
|
|
191
195
|
}
|
|
196
|
+
|
|
197
|
+
// ── Config-based API ────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export function renderLegendSvgFromConfig(
|
|
200
|
+
config: LegendConfig,
|
|
201
|
+
state: LegendState,
|
|
202
|
+
palette: LegendPalette & { isDark: boolean },
|
|
203
|
+
containerWidth: number
|
|
204
|
+
): LegendRenderResult {
|
|
205
|
+
// Delegate to existing renderer with adapted parameters
|
|
206
|
+
return renderLegendSvg(config.groups, {
|
|
207
|
+
palette: {
|
|
208
|
+
bg: palette.bg,
|
|
209
|
+
surface: palette.surface,
|
|
210
|
+
text: palette.text,
|
|
211
|
+
textMuted: palette.textMuted,
|
|
212
|
+
},
|
|
213
|
+
isDark: palette.isDark,
|
|
214
|
+
containerWidth,
|
|
215
|
+
activeGroup: state.activeGroup,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Centralized legend system — shared type definitions
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { Selection } from 'd3-selection';
|
|
6
|
+
|
|
7
|
+
// Re-export from legend-svg.ts for backward compat
|
|
8
|
+
export type { LegendGroupData } from './legend-svg';
|
|
9
|
+
|
|
10
|
+
// ── State ───────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface LegendState {
|
|
13
|
+
activeGroup: string | null;
|
|
14
|
+
hiddenAttributes?: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LegendCallbacks {
|
|
18
|
+
onGroupToggle?: (groupName: string) => void;
|
|
19
|
+
onVisibilityToggle?: (attribute: string) => void;
|
|
20
|
+
onStateChange?: (newState: LegendState) => void;
|
|
21
|
+
/** Called when an entry is hovered. Chart renderers can use this for cross-element highlighting. */
|
|
22
|
+
onEntryHover?: (groupName: string, entryValue: string | null) => void;
|
|
23
|
+
/** Called after each group <g> is rendered — lets chart renderers inject custom elements (swimlane icons, etc.) */
|
|
24
|
+
onGroupRendered?: (
|
|
25
|
+
groupName: string,
|
|
26
|
+
groupEl: D3Sel,
|
|
27
|
+
isActive: boolean
|
|
28
|
+
) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Position & Layout ───────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export interface LegendPosition {
|
|
34
|
+
placement: 'top-center';
|
|
35
|
+
titleRelation: 'below-title' | 'inline-with-title';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type LegendMode = 'fixed' | 'inline';
|
|
39
|
+
|
|
40
|
+
export type LegendControlExportBehavior = 'include' | 'strip' | 'static';
|
|
41
|
+
|
|
42
|
+
export interface LegendControl {
|
|
43
|
+
id: string;
|
|
44
|
+
/** SVG markup for the control icon, or a string label */
|
|
45
|
+
icon: string;
|
|
46
|
+
label?: string;
|
|
47
|
+
exportBehavior: LegendControlExportBehavior;
|
|
48
|
+
onClick?: () => void;
|
|
49
|
+
children?: LegendControlEntry[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LegendControlEntry {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
isActive?: boolean;
|
|
56
|
+
onClick?: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Config ──────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface LegendConfig {
|
|
62
|
+
groups: import('./legend-svg').LegendGroupData[];
|
|
63
|
+
position: LegendPosition;
|
|
64
|
+
controls?: LegendControl[];
|
|
65
|
+
mode: LegendMode;
|
|
66
|
+
/** Title width in pixels — used for inline-with-title computation */
|
|
67
|
+
titleWidth?: number;
|
|
68
|
+
/** Extra width (px) reserved after the pill inside an active capsule (e.g. for eye icon addon). Entries start after this offset. */
|
|
69
|
+
capsulePillAddonWidth?: number;
|
|
70
|
+
/** When true, groups with no entries are still rendered as collapsed pills. Default: false (empty groups hidden). */
|
|
71
|
+
showEmptyGroups?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface LegendPalette {
|
|
75
|
+
bg: string;
|
|
76
|
+
surface: string;
|
|
77
|
+
text: string;
|
|
78
|
+
textMuted: string;
|
|
79
|
+
primary?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Layout output ───────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface LegendPillLayout {
|
|
85
|
+
groupName: string;
|
|
86
|
+
x: number;
|
|
87
|
+
y: number;
|
|
88
|
+
width: number;
|
|
89
|
+
height: number;
|
|
90
|
+
isActive: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface LegendEntryLayout {
|
|
94
|
+
value: string;
|
|
95
|
+
color: string;
|
|
96
|
+
x: number;
|
|
97
|
+
y: number;
|
|
98
|
+
dotCx: number;
|
|
99
|
+
dotCy: number;
|
|
100
|
+
textX: number;
|
|
101
|
+
textY: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface LegendCapsuleLayout {
|
|
105
|
+
groupName: string;
|
|
106
|
+
x: number;
|
|
107
|
+
y: number;
|
|
108
|
+
width: number;
|
|
109
|
+
height: number;
|
|
110
|
+
pill: LegendPillLayout;
|
|
111
|
+
entries: LegendEntryLayout[];
|
|
112
|
+
/** Overflow indicator when entries exceed max rows */
|
|
113
|
+
moreCount?: number;
|
|
114
|
+
/** X offset where addon content (e.g. eye icon) can be placed — after pill, before entries */
|
|
115
|
+
addonX?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface LegendControlLayout {
|
|
119
|
+
id: string;
|
|
120
|
+
x: number;
|
|
121
|
+
y: number;
|
|
122
|
+
width: number;
|
|
123
|
+
height: number;
|
|
124
|
+
icon: string;
|
|
125
|
+
label?: string;
|
|
126
|
+
exportBehavior: LegendControlExportBehavior;
|
|
127
|
+
children?: Array<{
|
|
128
|
+
id: string;
|
|
129
|
+
label: string;
|
|
130
|
+
x: number;
|
|
131
|
+
y: number;
|
|
132
|
+
width: number;
|
|
133
|
+
isActive?: boolean;
|
|
134
|
+
}>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface LegendRowLayout {
|
|
138
|
+
y: number;
|
|
139
|
+
items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface LegendLayout {
|
|
143
|
+
/** Total computed height including all rows */
|
|
144
|
+
height: number;
|
|
145
|
+
/** Total computed width */
|
|
146
|
+
width: number;
|
|
147
|
+
/** Rows of legend elements (pills wrap to new rows on overflow) */
|
|
148
|
+
rows: LegendRowLayout[];
|
|
149
|
+
/** Active capsule layout (if any group is active) */
|
|
150
|
+
activeCapsule?: LegendCapsuleLayout;
|
|
151
|
+
/** Control layouts (right-aligned) */
|
|
152
|
+
controls: LegendControlLayout[];
|
|
153
|
+
/** All pill layouts (collapsed groups) */
|
|
154
|
+
pills: LegendPillLayout[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Handle ──────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export interface LegendHandle {
|
|
160
|
+
setState: (state: LegendState) => void;
|
|
161
|
+
destroy: () => void;
|
|
162
|
+
getHeight: () => number;
|
|
163
|
+
getLayout: () => LegendLayout;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── D3 selection shorthand ──────────────────────────────────
|
|
167
|
+
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
169
|
+
export type D3Sel = Selection<any, unknown, any, unknown>;
|