@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.
@@ -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
+ }
@@ -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>;