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