@diagrammo/dgmo 0.8.9 β†’ 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.
@@ -11,20 +11,14 @@ import type { ParsedSitemap } from './types';
11
11
  import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
12
12
  import {
13
13
  LEGEND_HEIGHT,
14
- LEGEND_PILL_PAD,
15
- LEGEND_PILL_FONT_SIZE,
16
- LEGEND_CAPSULE_PAD,
17
- LEGEND_DOT_R,
18
- LEGEND_ENTRY_FONT_SIZE,
19
- LEGEND_ENTRY_DOT_GAP,
20
- LEGEND_ENTRY_TRAIL,
21
14
  LEGEND_GROUP_GAP,
22
15
  LEGEND_EYE_SIZE,
23
16
  LEGEND_EYE_GAP,
24
17
  EYE_OPEN_PATH,
25
18
  EYE_CLOSED_PATH,
26
- measureLegendText,
27
19
  } from '../utils/legend-constants';
20
+ import { renderLegendD3 } from '../utils/legend-d3';
21
+ import type { LegendConfig, LegendState } from '../utils/legend-types';
28
22
 
29
23
  // ============================================================
30
24
  // Constants
@@ -98,6 +92,12 @@ const lineGenerator = d3Shape
98
92
  .y((d) => d.y)
99
93
  .curve(d3Shape.curveBasis);
100
94
 
95
+ const lineGeneratorLinear = d3Shape
96
+ .line<{ x: number; y: number }>()
97
+ .x((d) => d.x)
98
+ .y((d) => d.y)
99
+ .curve(d3Shape.curveLinear);
100
+
101
101
  // ============================================================
102
102
  // Main Renderer
103
103
  // ============================================================
@@ -380,7 +380,8 @@ export function renderSitemap(
380
380
  ? `sm-arrow-${edge.color.replace('#', '')}`
381
381
  : 'sm-arrow';
382
382
 
383
- const pathD = lineGenerator(edge.points);
383
+ const gen = edge.deferred ? lineGeneratorLinear : lineGenerator;
384
+ const pathD = gen(edge.points);
384
385
  if (pathD) {
385
386
  edgeG
386
387
  .append('path')
@@ -617,164 +618,75 @@ function renderLegend(
617
618
  ): void {
618
619
  if (legendGroups.length === 0) return;
619
620
 
620
- const visibleGroups =
621
- activeTagGroup != null
622
- ? legendGroups.filter(
623
- (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
624
- )
625
- : legendGroups;
626
-
627
- const groupBg = isDark
628
- ? mix(palette.surface, palette.bg, 50)
629
- : mix(palette.surface, palette.bg, 30);
630
-
631
- // For fixed legend: compute pixel-space positions centered in SVG width
632
- let fixedPositions: Map<string, number> | undefined;
633
- if (fixedWidth != null && visibleGroups.length > 0) {
634
- fixedPositions = new Map();
635
- const effectiveW = (g: SitemapLegendGroup) =>
636
- activeTagGroup != null ? g.width : g.minifiedWidth;
637
- const totalW =
638
- visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
639
- (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
640
- let cx = (fixedWidth - totalW) / 2;
641
- for (const g of visibleGroups) {
642
- fixedPositions.set(g.name, cx);
643
- cx += effectiveW(g) + LEGEND_GROUP_GAP;
644
- }
645
- }
646
-
647
- for (const group of visibleGroups) {
648
- const isActive = activeTagGroup != null;
649
- const pillW =
650
- measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
651
-
652
- const gX = fixedPositions?.get(group.name) ?? group.x;
653
- const gY = fixedPositions ? 0 : group.y;
654
-
655
- const legendG = parent
656
- .append('g')
657
- .attr('transform', `translate(${gX}, ${gY})`)
658
- .attr('class', 'sitemap-legend-group')
659
- .attr('data-legend-group', group.name.toLowerCase())
660
- .style('cursor', 'pointer');
661
-
662
- // Outer capsule background (active/expanded only)
663
- if (isActive) {
664
- legendG
665
- .append('rect')
666
- .attr('width', group.width)
667
- .attr('height', LEGEND_HEIGHT)
668
- .attr('rx', LEGEND_HEIGHT / 2)
669
- .attr('fill', groupBg);
670
- }
671
-
672
- const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
673
- const pillYOff = LEGEND_CAPSULE_PAD;
674
- const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
675
-
676
- // Pill background
677
- legendG
678
- .append('rect')
679
- .attr('x', pillXOff)
680
- .attr('y', pillYOff)
681
- .attr('width', pillW)
682
- .attr('height', pillH)
683
- .attr('rx', pillH / 2)
684
- .attr('fill', isActive ? palette.bg : groupBg);
685
-
686
- // Active pill border
687
- if (isActive) {
688
- legendG
689
- .append('rect')
690
- .attr('x', pillXOff)
691
- .attr('y', pillYOff)
692
- .attr('width', pillW)
693
- .attr('height', pillH)
694
- .attr('rx', pillH / 2)
695
- .attr('fill', 'none')
696
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
697
- .attr('stroke-width', 0.75);
698
- }
621
+ const groups = legendGroups.map((g) => ({
622
+ name: g.name,
623
+ entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
624
+ }));
625
+
626
+ const isFixedMode = fixedWidth != null;
627
+ const eyeAddonWidth = isFixedMode ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
628
+
629
+ const legendConfig: LegendConfig = {
630
+ groups,
631
+ position: { placement: 'top-center', titleRelation: 'below-title' },
632
+ mode: 'fixed',
633
+ capsulePillAddonWidth: eyeAddonWidth,
634
+ };
635
+ const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
636
+ const containerWidth =
637
+ fixedWidth ?? legendGroups[0]?.x + (legendGroups[0]?.width ?? 200);
638
+
639
+ const legendHandle = renderLegendD3(
640
+ parent,
641
+ legendConfig,
642
+ legendState,
643
+ palette,
644
+ isDark,
645
+ undefined,
646
+ containerWidth
647
+ );
699
648
 
700
- // Pill text
701
- legendG
702
- .append('text')
703
- .attr('x', pillXOff + pillW / 2)
704
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
705
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
706
- .attr('font-weight', '500')
707
- .attr('fill', isActive ? palette.text : palette.textMuted)
708
- .attr('text-anchor', 'middle')
709
- .text(group.name);
649
+ parent.selectAll('[data-legend-group]').classed('sitemap-legend-group', true);
710
650
 
711
- // Eye icon for visibility toggle (active only, app mode)
712
- if (isActive && fixedWidth != null) {
713
- const groupKey = group.name.toLowerCase();
651
+ // Inject eye icons into active group capsules (fixed/app mode only)
652
+ if (isFixedMode) {
653
+ const computedLayout = legendHandle.getLayout();
654
+ if (computedLayout.activeCapsule?.addonX != null) {
655
+ const capsule = computedLayout.activeCapsule;
656
+ const groupKey = capsule.groupName.toLowerCase();
714
657
  const isHidden = hiddenAttributes?.has(groupKey) ?? false;
715
- const eyeX = pillXOff + pillW + LEGEND_EYE_GAP;
716
- const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
717
- const hitPad = 6;
718
-
719
- const eyeG = legendG
720
- .append('g')
721
- .attr('class', 'sitemap-legend-eye')
722
- .attr('data-legend-visibility', groupKey)
723
- .style('cursor', 'pointer')
724
- .attr('opacity', isHidden ? 0.4 : 0.7);
725
658
 
726
- // Transparent hit area for easier clicking
727
- eyeG
728
- .append('rect')
729
- .attr('x', eyeX - hitPad)
730
- .attr('y', eyeY - hitPad)
731
- .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
732
- .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
733
- .attr('fill', 'transparent')
734
- .attr('pointer-events', 'all');
735
-
736
- eyeG
737
- .append('path')
738
- .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
739
- .attr('transform', `translate(${eyeX}, ${eyeY})`)
740
- .attr('fill', 'none')
741
- .attr('stroke', palette.textMuted)
742
- .attr('stroke-width', 1.2)
743
- .attr('stroke-linecap', 'round')
744
- .attr('stroke-linejoin', 'round');
745
- }
659
+ const activeGroupEl = parent.select(`[data-legend-group="${groupKey}"]`);
660
+ if (!activeGroupEl.empty()) {
661
+ const eyeX = capsule.addonX!;
662
+ const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
663
+ const hitPad = 6;
746
664
 
747
- // Entries (active/expanded only)
748
- if (isActive) {
749
- const eyeShift =
750
- fixedWidth != null ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
751
- let entryX = pillXOff + pillW + 4 + eyeShift;
752
- for (const entry of group.entries) {
753
- const entryG = legendG
665
+ const eyeG = activeGroupEl
754
666
  .append('g')
755
- .attr('data-legend-entry', entry.value.toLowerCase())
756
- .style('cursor', 'pointer');
757
-
758
- entryG
759
- .append('circle')
760
- .attr('cx', entryX + LEGEND_DOT_R)
761
- .attr('cy', LEGEND_HEIGHT / 2)
762
- .attr('r', LEGEND_DOT_R)
763
- .attr('fill', entry.color);
764
-
765
- const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
766
- entryG
767
- .append('text')
768
- .attr('x', textX)
769
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
770
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
771
- .attr('fill', palette.textMuted)
772
- .text(entry.value);
773
-
774
- entryX =
775
- textX +
776
- measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
777
- LEGEND_ENTRY_TRAIL;
667
+ .attr('class', 'sitemap-legend-eye')
668
+ .attr('data-legend-visibility', groupKey)
669
+ .style('cursor', 'pointer')
670
+ .attr('opacity', isHidden ? 0.4 : 0.7);
671
+
672
+ eyeG
673
+ .append('rect')
674
+ .attr('x', eyeX - hitPad)
675
+ .attr('y', eyeY - hitPad)
676
+ .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
677
+ .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
678
+ .attr('fill', 'transparent')
679
+ .attr('pointer-events', 'all');
680
+
681
+ eyeG
682
+ .append('path')
683
+ .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
684
+ .attr('transform', `translate(${eyeX}, ${eyeY})`)
685
+ .attr('fill', 'none')
686
+ .attr('stroke', palette.textMuted)
687
+ .attr('stroke-width', 1.2)
688
+ .attr('stroke-linecap', 'round')
689
+ .attr('stroke-linejoin', 'round');
778
690
  }
779
691
  }
780
692
  }
@@ -16,6 +16,12 @@ export const LEGEND_EYE_SIZE = 14;
16
16
  export const LEGEND_EYE_GAP = 6;
17
17
  export const LEGEND_ICON_W = 20;
18
18
 
19
+ // ── Spacing constants (centralized legend system) ───────────
20
+ export const LEGEND_TOP_PAD = 12;
21
+ export const LEGEND_TITLE_GAP = 8;
22
+ export const LEGEND_CONTENT_GAP = 12;
23
+ export const LEGEND_MAX_ENTRY_ROWS = 3;
24
+
19
25
  // ── Proportional text measurement ────────────────────────────
20
26
  // Helvetica character width ratios (fraction of fontSize).
21
27
  // Replaces the naive `chars * 0.6 * fontSize` estimate with
@@ -0,0 +1,400 @@
1
+ // ============================================================
2
+ // Centralized legend D3 renderer
3
+ // Thin adapter: layout engine β†’ D3 DOM
4
+ // ============================================================
5
+
6
+ import {
7
+ LEGEND_HEIGHT,
8
+ LEGEND_PILL_FONT_SIZE,
9
+ LEGEND_DOT_R,
10
+ LEGEND_ENTRY_FONT_SIZE,
11
+ LEGEND_ENTRY_DOT_GAP,
12
+ measureLegendText,
13
+ } from './legend-constants';
14
+ import { computeLegendLayout } from './legend-layout';
15
+ import { mix } from '../palettes/color-utils';
16
+ import { FONT_FAMILY } from '../fonts';
17
+ import type {
18
+ LegendConfig,
19
+ LegendState,
20
+ LegendCallbacks,
21
+ LegendHandle,
22
+ LegendPalette,
23
+ LegendLayout,
24
+ LegendPillLayout,
25
+ LegendCapsuleLayout,
26
+ LegendControlLayout,
27
+ D3Sel,
28
+ } from './legend-types';
29
+
30
+ // ── Main renderer ───────────────────────────────────────────
31
+
32
+ export function renderLegendD3(
33
+ container: D3Sel,
34
+ config: LegendConfig,
35
+ state: LegendState,
36
+ palette: LegendPalette,
37
+ isDark: boolean,
38
+ callbacks?: LegendCallbacks,
39
+ containerWidth?: number
40
+ ): LegendHandle {
41
+ const width = containerWidth ?? parseFloat(container.attr('width') || '800');
42
+ let currentState = { ...state };
43
+ let currentLayout: LegendLayout;
44
+
45
+ const legendG = container.append('g').attr('class', 'dgmo-legend');
46
+
47
+ function render() {
48
+ currentLayout = computeLegendLayout(config, currentState, width);
49
+ legendG.selectAll('*').remove();
50
+
51
+ if (currentLayout.height === 0) return;
52
+
53
+ // Set active attribute on container
54
+ if (currentState.activeGroup) {
55
+ legendG.attr(
56
+ 'data-legend-active',
57
+ currentState.activeGroup.toLowerCase()
58
+ );
59
+ } else {
60
+ legendG.attr('data-legend-active', null);
61
+ }
62
+
63
+ const groupBg = isDark
64
+ ? mix(palette.surface, palette.bg, 50)
65
+ : mix(palette.surface, palette.bg, 30);
66
+ const pillBorder = mix(palette.textMuted, palette.bg, 50);
67
+
68
+ // Render active capsule
69
+ if (currentLayout.activeCapsule) {
70
+ renderCapsule(
71
+ legendG,
72
+ currentLayout.activeCapsule,
73
+ palette,
74
+ groupBg,
75
+ pillBorder,
76
+ isDark,
77
+ callbacks
78
+ );
79
+ }
80
+
81
+ // Render collapsed pills
82
+ for (const pill of currentLayout.pills) {
83
+ renderPill(legendG, pill, palette, groupBg, callbacks);
84
+ }
85
+
86
+ // Render controls
87
+ for (const ctrl of currentLayout.controls) {
88
+ renderControl(
89
+ legendG,
90
+ ctrl,
91
+ palette,
92
+ groupBg,
93
+ pillBorder,
94
+ isDark,
95
+ config.controls
96
+ );
97
+ }
98
+ }
99
+
100
+ render();
101
+
102
+ return {
103
+ setState(newState: LegendState) {
104
+ currentState = { ...newState };
105
+ render();
106
+ },
107
+ destroy() {
108
+ legendG.remove();
109
+ },
110
+ getHeight() {
111
+ return currentLayout?.height ?? 0;
112
+ },
113
+ getLayout() {
114
+ return currentLayout;
115
+ },
116
+ };
117
+ }
118
+
119
+ // ── Capsule (active group) ──────────────────────────────────
120
+
121
+ function renderCapsule(
122
+ parent: D3Sel,
123
+ capsule: LegendCapsuleLayout,
124
+ palette: LegendPalette,
125
+ groupBg: string,
126
+ pillBorder: string,
127
+ _isDark: boolean,
128
+ callbacks?: LegendCallbacks
129
+ ): void {
130
+ const g = parent
131
+ .append('g')
132
+ .attr('transform', `translate(${capsule.x},${capsule.y})`)
133
+ .attr('data-legend-group', capsule.groupName.toLowerCase())
134
+ .style('cursor', 'pointer');
135
+
136
+ // Outer capsule background
137
+ g.append('rect')
138
+ .attr('width', capsule.width)
139
+ .attr('height', capsule.height)
140
+ .attr('rx', LEGEND_HEIGHT / 2)
141
+ .attr('fill', groupBg);
142
+
143
+ // Inner pill
144
+ const pill = capsule.pill;
145
+ g.append('rect')
146
+ .attr('x', pill.x)
147
+ .attr('y', pill.y)
148
+ .attr('width', pill.width)
149
+ .attr('height', pill.height)
150
+ .attr('rx', pill.height / 2)
151
+ .attr('fill', palette.bg);
152
+
153
+ // Pill border
154
+ g.append('rect')
155
+ .attr('x', pill.x)
156
+ .attr('y', pill.y)
157
+ .attr('width', pill.width)
158
+ .attr('height', pill.height)
159
+ .attr('rx', pill.height / 2)
160
+ .attr('fill', 'none')
161
+ .attr('stroke', pillBorder)
162
+ .attr('stroke-width', 0.75);
163
+
164
+ // Pill text
165
+ g.append('text')
166
+ .attr('x', pill.x + pill.width / 2)
167
+ .attr('y', LEGEND_HEIGHT / 2)
168
+ .attr('text-anchor', 'middle')
169
+ .attr('dominant-baseline', 'central')
170
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
171
+ .attr('font-weight', 500)
172
+ .attr('fill', palette.text)
173
+ .attr('pointer-events', 'none')
174
+ .attr('font-family', FONT_FAMILY)
175
+ .text(capsule.groupName);
176
+
177
+ // Entry dots + labels
178
+ for (const entry of capsule.entries) {
179
+ const entryG = g
180
+ .append('g')
181
+ .attr('data-legend-entry', entry.value.toLowerCase())
182
+ .attr('data-series-name', entry.value)
183
+ .style('cursor', 'pointer');
184
+
185
+ entryG
186
+ .append('circle')
187
+ .attr('cx', entry.dotCx)
188
+ .attr('cy', entry.dotCy)
189
+ .attr('r', LEGEND_DOT_R)
190
+ .attr('fill', entry.color);
191
+
192
+ entryG
193
+ .append('text')
194
+ .attr('x', entry.textX)
195
+ .attr('y', entry.textY)
196
+ .attr('dominant-baseline', 'central')
197
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
198
+ .attr('fill', palette.textMuted)
199
+ .attr('font-family', FONT_FAMILY)
200
+ .text(entry.value);
201
+
202
+ // Entry hover callback for chart-level highlighting
203
+ if (callbacks?.onEntryHover) {
204
+ const groupName = capsule.groupName;
205
+ const entryValue = entry.value;
206
+ const onHover = callbacks.onEntryHover;
207
+ entryG
208
+ .on('mouseenter', () => onHover(groupName, entryValue))
209
+ .on('mouseleave', () => onHover(groupName, null));
210
+ }
211
+ }
212
+
213
+ // "+N more" indicator
214
+ if (capsule.moreCount) {
215
+ const lastEntry = capsule.entries[capsule.entries.length - 1];
216
+ const moreX = lastEntry
217
+ ? lastEntry.textX +
218
+ measureLegendText(lastEntry.value, LEGEND_ENTRY_FONT_SIZE) +
219
+ LEGEND_ENTRY_DOT_GAP * 2
220
+ : pill.x + pill.width + 8;
221
+ const moreY = lastEntry?.textY ?? LEGEND_HEIGHT / 2;
222
+
223
+ g.append('text')
224
+ .attr('x', moreX)
225
+ .attr('y', moreY)
226
+ .attr('dominant-baseline', 'central')
227
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
228
+ .attr('font-style', 'italic')
229
+ .attr('fill', palette.textMuted)
230
+ .attr('font-family', FONT_FAMILY)
231
+ .text(`+${capsule.moreCount} more`);
232
+ }
233
+
234
+ // Click handler on pill
235
+ if (callbacks?.onGroupToggle) {
236
+ const cb = callbacks.onGroupToggle;
237
+ const name = capsule.groupName;
238
+ g.on('click', () => cb(name));
239
+ }
240
+
241
+ // Post-render callback for chart-specific addons
242
+ if (callbacks?.onGroupRendered) {
243
+ callbacks.onGroupRendered(capsule.groupName, g, true);
244
+ }
245
+ }
246
+
247
+ // ── Collapsed pill ──────────────────────────────────────────
248
+
249
+ function renderPill(
250
+ parent: D3Sel,
251
+ pill: LegendPillLayout,
252
+ palette: LegendPalette,
253
+ groupBg: string,
254
+ callbacks?: LegendCallbacks
255
+ ): void {
256
+ const g = parent
257
+ .append('g')
258
+ .attr('transform', `translate(${pill.x},${pill.y})`)
259
+ .attr('data-legend-group', pill.groupName.toLowerCase())
260
+ .style('cursor', 'pointer');
261
+
262
+ g.append('rect')
263
+ .attr('width', pill.width)
264
+ .attr('height', pill.height)
265
+ .attr('rx', pill.height / 2)
266
+ .attr('fill', groupBg);
267
+
268
+ g.append('text')
269
+ .attr('x', pill.width / 2)
270
+ .attr('y', pill.height / 2)
271
+ .attr('text-anchor', 'middle')
272
+ .attr('dominant-baseline', 'central')
273
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
274
+ .attr('font-weight', 500)
275
+ .attr('fill', palette.textMuted)
276
+ .attr('pointer-events', 'none')
277
+ .attr('font-family', FONT_FAMILY)
278
+ .text(pill.groupName);
279
+
280
+ if (callbacks?.onGroupToggle) {
281
+ const cb = callbacks.onGroupToggle;
282
+ const name = pill.groupName;
283
+ g.on('click', () => cb(name));
284
+ }
285
+
286
+ if (callbacks?.onGroupRendered) {
287
+ callbacks.onGroupRendered(pill.groupName, g, false);
288
+ }
289
+ }
290
+
291
+ // ── Control (right-aligned) ─────────────────────────────────
292
+
293
+ function renderControl(
294
+ parent: D3Sel,
295
+ ctrl: LegendControlLayout,
296
+ palette: LegendPalette,
297
+ _groupBg: string,
298
+ pillBorder: string,
299
+ _isDark: boolean,
300
+ configControls?: LegendConfig['controls']
301
+ ): void {
302
+ const g = parent
303
+ .append('g')
304
+ .attr('transform', `translate(${ctrl.x},${ctrl.y})`)
305
+ .attr('data-legend-control', ctrl.id)
306
+ .style('cursor', 'pointer');
307
+
308
+ if (ctrl.exportBehavior === 'strip') {
309
+ g.attr('data-export-ignore', 'true');
310
+ }
311
+
312
+ // Outline pill style for controls
313
+ g.append('rect')
314
+ .attr('width', ctrl.width)
315
+ .attr('height', ctrl.height)
316
+ .attr('rx', ctrl.height / 2)
317
+ .attr('fill', 'none')
318
+ .attr('stroke', pillBorder)
319
+ .attr('stroke-width', 0.75);
320
+
321
+ // Icon + label
322
+ let textX = ctrl.width / 2;
323
+ if (ctrl.icon && ctrl.label) {
324
+ // Icon on left, label on right
325
+ const iconG = g
326
+ .append('g')
327
+ .attr('transform', `translate(8,${(ctrl.height - 14) / 2})`);
328
+ iconG.html(ctrl.icon);
329
+ textX =
330
+ 8 +
331
+ 14 +
332
+ LEGEND_ENTRY_DOT_GAP +
333
+ measureLegendText(ctrl.label, LEGEND_PILL_FONT_SIZE) / 2;
334
+ }
335
+
336
+ if (ctrl.label) {
337
+ g.append('text')
338
+ .attr('x', textX)
339
+ .attr('y', ctrl.height / 2)
340
+ .attr('text-anchor', 'middle')
341
+ .attr('dominant-baseline', 'central')
342
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
343
+ .attr('font-weight', 500)
344
+ .attr('fill', palette.textMuted)
345
+ .attr('pointer-events', 'none')
346
+ .attr('font-family', FONT_FAMILY)
347
+ .text(ctrl.label);
348
+ }
349
+
350
+ // Children (e.g., speed badges)
351
+ if (ctrl.children) {
352
+ let cx = ctrl.width + 4;
353
+ for (const child of ctrl.children) {
354
+ const childG = g
355
+ .append('g')
356
+ .attr('transform', `translate(${cx},0)`)
357
+ .style('cursor', 'pointer');
358
+
359
+ childG
360
+ .append('rect')
361
+ .attr('width', child.width)
362
+ .attr('height', ctrl.height)
363
+ .attr('rx', ctrl.height / 2)
364
+ .attr(
365
+ 'fill',
366
+ child.isActive ? (palette.primary ?? palette.text) : 'none'
367
+ )
368
+ .attr('stroke', pillBorder)
369
+ .attr('stroke-width', 0.75);
370
+
371
+ childG
372
+ .append('text')
373
+ .attr('x', child.width / 2)
374
+ .attr('y', ctrl.height / 2)
375
+ .attr('text-anchor', 'middle')
376
+ .attr('dominant-baseline', 'central')
377
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
378
+ .attr('fill', child.isActive ? palette.bg : palette.textMuted)
379
+ .attr('font-family', FONT_FAMILY)
380
+ .text(child.label);
381
+
382
+ // Click handler for child
383
+ const configCtrl = configControls?.find((c) => c.id === ctrl.id);
384
+ const configChild = configCtrl?.children?.find((c) => c.id === child.id);
385
+ if (configChild?.onClick) {
386
+ const onClick = configChild.onClick;
387
+ childG.on('click', () => onClick());
388
+ }
389
+
390
+ cx += child.width + 4;
391
+ }
392
+ }
393
+
394
+ // Click handler
395
+ const configCtrl = configControls?.find((c) => c.id === ctrl.id);
396
+ if (configCtrl?.onClick) {
397
+ const onClick = configCtrl.onClick;
398
+ g.on('click', () => onClick());
399
+ }
400
+ }