@adia-ai/web-components 0.0.13 → 0.0.15

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.
@@ -196,8 +196,9 @@ class AdiaChart extends AdiaElement {
196
196
  hideValues: { type: Boolean, default: false, reflect: true, attribute: 'hide-values' },
197
197
  radius: { type: Number, default: null, reflect: true },
198
198
  smooth: { type: Number, default: 0.4, reflect: true },
199
- aspect: { type: String, default: 'std', reflect: true }, // std | wide | square | tall
199
+ aspect: { type: String, default: 'std', reflect: true }, // std | wide | square | tall (deprecated — OD-CHART-02)
200
200
  size: { type: String, default: '', reflect: true }, // sm | md | lg
201
+ format: { type: String, default: 'abbr', reflect: true }, // abbr | decimal | currency | percent (OD-CHART-03)
201
202
  };
202
203
 
203
204
  static template = () => null;
@@ -207,6 +208,9 @@ class AdiaChart extends AdiaElement {
207
208
  #resizeRaf = null;
208
209
  #lastW = 0;
209
210
  #lastH = 0;
211
+ /* Set of series keys hidden by external chart-legend-ui[for=self] toggles.
212
+ Kept at render time; repopulated lookups rebuild with the new set. */
213
+ #hiddenSeriesKeys = new Set();
210
214
 
211
215
  /** Resolves the corner radius: the `radius` prop if explicitly set,
212
216
  * otherwise `--a-radius` from the host's computed style. Falls back
@@ -220,6 +224,7 @@ class AdiaChart extends AdiaElement {
220
224
 
221
225
  set data(arr) {
222
226
  this.#data = Array.isArray(arr) ? arr : [];
227
+ this.#warnIfOverBudget();
223
228
  this.#renderChart();
224
229
  }
225
230
 
@@ -227,7 +232,55 @@ class AdiaChart extends AdiaElement {
227
232
  return this.#data;
228
233
  }
229
234
 
235
+ /* OD-CHART-11 — one-shot perf-budget warning. Chart-ui re-renders the
236
+ full SVG string on every data change; perf drops noticeably past
237
+ ~5,000 datums (the tipping point varies by type — scatter and
238
+ multi-line are the worst offenders). Authors over the budget should
239
+ downsample at the data layer (LTTB, uniform sampling, aggregation
240
+ by bucket) before setting .data. Override the threshold via the
241
+ `--chart-perf-budget` CSS token for ad-hoc testing. */
242
+ #perfBudgetWarned = false;
243
+ #warnIfOverBudget() {
244
+ if (this.#perfBudgetWarned) return;
245
+ const budget = this.#readPerfBudget();
246
+ if (this.#data.length > budget) {
247
+ console.warn(
248
+ `[chart-ui] .data has ${this.#data.length} rows which exceeds the ` +
249
+ `recommended perf budget of ${budget}. Consider downsampling at the ` +
250
+ `data layer (e.g., LTTB, bucket aggregation). Override the budget ` +
251
+ `via CSS: chart-ui { --chart-perf-budget: 10000 }.`
252
+ );
253
+ this.#perfBudgetWarned = true;
254
+ }
255
+ }
256
+ #readPerfBudget() {
257
+ const raw = getComputedStyle(this).getPropertyValue('--chart-perf-budget').trim();
258
+ const n = parseInt(raw, 10);
259
+ return Number.isFinite(n) && n > 0 ? n : 5000;
260
+ }
261
+
230
262
  connected() {
263
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'img');
264
+ if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', this.heading || `${this.type} chart`);
265
+
266
+ /* Phase 1b — listen for legend-toggle events from external
267
+ chart-legend-ui[for=self] and hide the matching series. Events
268
+ bubble so document-level listening catches descendant legends. */
269
+ document.addEventListener('legend-toggle', this.#onLegendToggle);
270
+
271
+ /* OD-CHART-06 — keyboard a11y. Chart becomes focusable; arrow keys
272
+ move a virtual focus across datums in DOM order, Enter/Space fires
273
+ chart-select, Escape clears focus. Per-datum focus dispatches the
274
+ same chart-hover event the pointer path uses, so tooltip-ui[for]
275
+ tracks keyboard focus transparently.
276
+ Deprecation warnings for `aspect=` and `heading=` also fire here
277
+ per OD-CHART-02 — one-shot per instance to keep the console clean. */
278
+ if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
279
+ this.addEventListener('keydown', this.#onKeydown);
280
+ this.addEventListener('focus', this.#onFocus);
281
+ this.addEventListener('blur', this.#onBlur);
282
+ this.#warnDeprecatedAttrs();
283
+
231
284
  this.#resizeObs = new ResizeObserver((entries) => {
232
285
  const { inlineSize: w, blockSize: h } = entries[0].contentBoxSize[0];
233
286
  if (!this.#data.length) return;
@@ -306,11 +359,31 @@ class AdiaChart extends AdiaElement {
306
359
 
307
360
  /* ── Main render ──────────────────────────────────────────────── */
308
361
 
362
+ #emptySlotTemplate = null;
363
+
309
364
  #renderChart() {
310
365
  if (!this.isConnected) return;
366
+
367
+ /* Preserve the empty slot across re-renders. First render captures the
368
+ author-provided <* slot="empty"> into a template; subsequent renders
369
+ restore it so data-empty → data-present → data-empty transitions
370
+ don't lose the slotted empty-state-ui. Visibility is CSS-toggled via
371
+ [data-has-data] on the host. */
372
+ const existingEmpty = this.querySelector(':scope > [slot="empty"]');
373
+ if (existingEmpty && !this.#emptySlotTemplate) {
374
+ this.#emptySlotTemplate = existingEmpty.cloneNode(true);
375
+ }
376
+
311
377
  this.innerHTML = '';
378
+ if (this.#emptySlotTemplate) this.appendChild(this.#emptySlotTemplate.cloneNode(true));
312
379
 
313
- if (!this.#data.length) return;
380
+ if (!this.#data.length) {
381
+ this.removeAttribute('data-has-data');
382
+ return;
383
+ }
384
+ this.setAttribute('data-has-data', '');
385
+
386
+ this.#injectSeriesColors();
314
387
 
315
388
  if (this.heading) {
316
389
  const headingEl = document.createElement('div');
@@ -337,6 +410,14 @@ class AdiaChart extends AdiaElement {
337
410
  case 'radar': ({ svg: svgContent, viewBox: vb } = this.#renderRadar()); break;
338
411
  case 'sparkline': ({ svg: svgContent, viewBox: vb } = this.#renderSparkline()); break;
339
412
  case 'segments': ({ svg: svgContent, viewBox: vb } = this.#renderSegments()); break;
413
+ case 'area': ({ svg: svgContent, viewBox: vb } = this.#renderArea()); break;
414
+ case 'scatter': ({ svg: svgContent, viewBox: vb } = this.#renderScatter()); break;
415
+ case 'radial-bar': ({ svg: svgContent, viewBox: vb } = this.#renderRadialBar()); break;
416
+ case 'gauge': ({ svg: svgContent, viewBox: vb } = this.#renderGauge()); break;
417
+ case 'funnel': ({ svg: svgContent, viewBox: vb } = this.#renderFunnel()); break;
418
+ case 'treemap': ({ svg: svgContent, viewBox: vb } = this.#renderTreemap()); break;
419
+ case 'sankey': ({ svg: svgContent, viewBox: vb } = this.#renderSankey()); break;
420
+ case 'composed': ({ svg: svgContent, viewBox: vb } = this.#renderComposed()); break;
340
421
  case 'stacked-bar': ({ svg: svgContent, viewBox: vb } = this.#renderStackedBar()); break;
341
422
  case 'grouped-bar': ({ svg: svgContent, viewBox: vb } = this.#renderGroupedBar()); break;
342
423
  case 'multi-line': ({ svg: svgContent, viewBox: vb } = this.#renderMultiLine()); break;
@@ -347,18 +428,59 @@ class AdiaChart extends AdiaElement {
347
428
  svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
348
429
  svgEl.innerHTML = svgContent;
349
430
 
350
- /* Append legend for types that need it */
351
- if (['pie', 'donut', 'stacked-bar', 'grouped-bar', 'multi-line'].includes(this.type)) {
431
+ /* Append legend for types that need it. Legend data is also exposed via
432
+ the public `legendData` getter so a sibling chart-legend-ui[for] can
433
+ mirror the same series. When an external legend is present (Phase 1b:
434
+ chart-legend-ui[for=self]), the internal one is suppressed so we
435
+ don't double-render — the #legendData remains populated for the
436
+ external to read. */
437
+ if (['pie', 'donut', 'stacked-bar', 'grouped-bar', 'multi-line', 'radial-bar'].includes(this.type)
438
+ && !this.#hasExternalLegend()) {
352
439
  const legend = this.#buildLegend();
353
440
  if (legend) this.appendChild(legend);
354
441
  }
355
442
 
356
- /* Wire hover tooltip pointer delegate on the SVG reads data-tip-*
357
- attrs from the shape under the cursor and shows a single shared
358
- popover. See #showTooltip / #hideTooltip below. */
443
+ /* Notify external legend/tooltip consumers that legendData has refreshed. */
444
+ this.dispatchEvent(new CustomEvent('legend-update', { bubbles: true }));
445
+
446
+ /* Wire hover tooltip + custom events. Internal tooltip (#tipEl) remains
447
+ for back-compat; Phase 2 retires it when tooltip-ui[follows=pointer]
448
+ lands. Custom events are additive — fire regardless of tooltip state.
449
+ OD-CHART-07: pointerdown handles touch (tap-to-pin); pointerover /
450
+ move still handle mouse + pen (`pointerType` gating inside each). */
359
451
  svgEl.addEventListener('pointerover', this.#onPointerOver);
360
452
  svgEl.addEventListener('pointermove', this.#onPointerMove);
361
453
  svgEl.addEventListener('pointerleave', this.#onPointerLeave);
454
+ svgEl.addEventListener('pointerdown', this.#onPointerDown);
455
+ svgEl.addEventListener('click', this.#onClick);
456
+ }
457
+
458
+ /* ── Per-series --color-{key} injection ──
459
+ For each declared series key (`y="revenue,users"`), emit an inline CSS
460
+ custom property on the host mapping the key to the categorical palette
461
+ slot it occupies. Consumers override `--color-revenue` at any ancestor
462
+ to recolor that series across chart + legend + (Phase 2) tooltip. */
463
+ #injectSeriesColors() {
464
+ const keys = this.#yKeys();
465
+ for (let i = 0; i < keys.length; i++) {
466
+ const slot = i % 10;
467
+ this.style.setProperty(`--color-${keys[i]}`, `var(--chart-${slot})`);
468
+ }
469
+ }
470
+
471
+ /* Render-time helpers — emit data-slice + series-key + inline style that
472
+ references `--color-{key}` with fallback to `--chart-{slot}`. Inline
473
+ style beats the stylesheet's data-slice rule, so consumer overrides of
474
+ --color-{key} at any ancestor recolor that series. Non-series-keyed
475
+ elements (pie/donut/segments categorical slots) keep data-slice only
476
+ and are coloured by the stylesheet. */
477
+ #seriesFill(slotIdx, seriesKey) {
478
+ if (!seriesKey) return ` data-slice="${slotIdx}"`;
479
+ return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="fill: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
480
+ }
481
+ #seriesStroke(slotIdx, seriesKey) {
482
+ if (!seriesKey) return ` data-slice="${slotIdx}"`;
483
+ return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="stroke: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
362
484
  }
363
485
 
364
486
  /* ── Tooltip ───────────────────────────────────────────────────── */
@@ -369,23 +491,378 @@ class AdiaChart extends AdiaElement {
369
491
  this.#resizeObs?.disconnect();
370
492
  this.#resizeObs = null;
371
493
  if (this.#resizeRaf) { cancelAnimationFrame(this.#resizeRaf); this.#resizeRaf = null; }
494
+ document.removeEventListener('legend-toggle', this.#onLegendToggle);
495
+ document.removeEventListener('pointerdown', this.#pinnedTouchDismiss);
496
+ this.removeEventListener('keydown', this.#onKeydown);
497
+ this.removeEventListener('focus', this.#onFocus);
498
+ this.removeEventListener('blur', this.#onBlur);
372
499
  this.#hideTooltip();
373
500
  }
374
501
 
502
+ /* Detect whether an external chart-legend-ui or tooltip-ui is acting as
503
+ this chart's legend / tooltip via [for=self.id]. When present, the
504
+ internal counterpart is suppressed so we don't double-render. */
505
+ #hasExternalLegend() {
506
+ if (!this.id) return false;
507
+ return !!document.querySelector(`chart-legend-ui[for="${CSS.escape(this.id)}"]`);
508
+ }
509
+ #hasExternalTooltip() {
510
+ if (!this.id) return false;
511
+ return !!document.querySelector(`tooltip-ui[follows="pointer"][for="${CSS.escape(this.id)}"]`);
512
+ }
513
+
514
+ #onLegendToggle = (event) => {
515
+ const legend = event.target?.closest?.('chart-legend-ui[for]');
516
+ if (!legend || legend.getAttribute('for') !== this.id) return;
517
+ const { key, active } = event.detail || {};
518
+ if (!key) return;
519
+ if (active) this.#hiddenSeriesKeys.delete(key);
520
+ else this.#hiddenSeriesKeys.add(key);
521
+ this.#renderChart();
522
+ };
523
+
524
+ #isSeriesHidden(key) {
525
+ return !!key && this.#hiddenSeriesKeys.has(key);
526
+ }
527
+
528
+ /* ── Deprecation warnings (OD-CHART-02) ─────────────────────────
529
+ `aspect=` and `heading=` were part of the pre-composition model —
530
+ `aspect` is stale because parents now size the chart; `heading`
531
+ because card headers are the semantic location for chart titles.
532
+ Warn once per instance so the console stays readable. Attrs still
533
+ honored; removal planned for the next major. */
534
+ #deprecationWarned = false;
535
+ #warnDeprecatedAttrs() {
536
+ if (this.#deprecationWarned) return;
537
+ if (this.aspect && this.aspect !== 'std') {
538
+ console.warn(
539
+ `[chart-ui] aspect="${this.aspect}" is deprecated. ` +
540
+ `Parents should size the chart directly (width/height on the card ` +
541
+ `or container). The attribute will be removed in a future major.`
542
+ );
543
+ this.#deprecationWarned = true;
544
+ }
545
+ if (this.heading) {
546
+ console.warn(
547
+ `[chart-ui] heading="${this.heading}" is deprecated. ` +
548
+ `Place the title in an enclosing card-ui <header><span slot="heading"> ` +
549
+ `instead. The attribute will be removed in a future major.`
550
+ );
551
+ this.#deprecationWarned = true;
552
+ }
553
+ }
554
+
555
+ /* ── Number formatting (OD-CHART-03) ────────────────────────────
556
+ `format` attribute selects how datum values are displayed on axis
557
+ labels, bar/line value overlays, donut centers, etc. Falls back to
558
+ the local `fmt()` helper for `abbr` (existing behavior). Currency
559
+ prefix reads --chart-currency-prefix (default "$") so consumers
560
+ retune per locale without touching the format attr itself. */
561
+ #fmtValue(v) {
562
+ if (v == null || v === '') return '';
563
+ const n = +v;
564
+ if (!Number.isFinite(n)) return String(v);
565
+ const fmtAttr = this.format || 'abbr';
566
+ switch (fmtAttr) {
567
+ case 'decimal': return n.toFixed(2);
568
+ case 'percent': return `${(n * 100).toFixed(1)}%`;
569
+ case 'currency': {
570
+ /* --chart-currency-prefix is a CSS custom property storing a string.
571
+ getComputedStyle returns it with the surrounding quotes ('"$"'),
572
+ which must be stripped before concatenation. Empty/unset falls
573
+ back to '$'. */
574
+ let prefix = getComputedStyle(this).getPropertyValue('--chart-currency-prefix').trim();
575
+ if ((prefix.startsWith('"') && prefix.endsWith('"')) ||
576
+ (prefix.startsWith("'") && prefix.endsWith("'"))) {
577
+ prefix = prefix.slice(1, -1);
578
+ }
579
+ return `${prefix || '$'}${fmt(n)}`;
580
+ }
581
+ case 'abbr':
582
+ default: return fmt(n);
583
+ }
584
+ }
585
+
586
+ /* ── Keyboard nav (OD-CHART-06) ─────────────────────────────────
587
+ Virtual focus across data points in DOM order — ArrowLeft/Right or
588
+ ArrowUp/Down step by one; Home/End jump to first/last; Enter/Space
589
+ fires chart-select; Escape clears focus and fires chart-leave. On
590
+ focus change, the chart emits the same chart-hover event shape as
591
+ the pointer path so tooltip-ui[follows=pointer][for] tracks keyboard
592
+ focus without needing its own code path.
593
+
594
+ Per-datum focus indicator is a data-a11y-focus attribute on the
595
+ focused element + a data-a11y-focused attribute on the host; CSS
596
+ paints the outline. */
597
+ #focusedDatumIdx = -1;
598
+
599
+ #datums() {
600
+ /* `[data-tip-label]` OR `[data-tip-value]` — every datum emits at
601
+ least one, so this catches every renderer. `circle[data-hit]` is
602
+ included intentionally (line/scatter uses a hit overlay as its
603
+ hover target). */
604
+ return Array.from(this.querySelectorAll('[data-tip-label], [data-tip-value]'));
605
+ }
606
+
607
+ #setFocusedDatum(idx) {
608
+ const datums = this.#datums();
609
+ if (!datums.length) { this.#clearFocusedDatum(); return; }
610
+ this.#focusedDatumIdx = Math.max(0, Math.min(idx, datums.length - 1));
611
+ this.#paintFocusIndicator();
612
+ this.#emitHoverForFocused();
613
+ }
614
+
615
+ #paintFocusIndicator() {
616
+ const datums = this.#datums();
617
+ /* Clear previous focus marker. */
618
+ for (const d of datums) d.removeAttribute('data-a11y-focus');
619
+ const el = datums[this.#focusedDatumIdx];
620
+ if (!el) return this.removeAttribute('data-a11y-focused');
621
+ el.setAttribute('data-a11y-focus', '');
622
+ this.setAttribute('data-a11y-focused', '');
623
+ }
624
+
625
+ #clearFocusedDatum() {
626
+ this.#focusedDatumIdx = -1;
627
+ for (const d of this.#datums()) d.removeAttribute('data-a11y-focus');
628
+ this.removeAttribute('data-a11y-focused');
629
+ }
630
+
631
+ #emitHoverForFocused() {
632
+ const el = this.#datums()[this.#focusedDatumIdx];
633
+ if (!el) return;
634
+ const rect = el.getBoundingClientRect();
635
+ const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
636
+ this.#emitHover(el, synthEvent);
637
+ }
638
+
639
+ #emitSelectForFocused() {
640
+ const el = this.#datums()[this.#focusedDatumIdx];
641
+ if (!el) return;
642
+ const rect = el.getBoundingClientRect();
643
+ const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
644
+ this.dispatchEvent(new CustomEvent('chart-select', {
645
+ bubbles: true,
646
+ detail: this.#tipPayload(el, synthEvent),
647
+ }));
648
+ }
649
+
650
+ #onFocus = () => {
651
+ /* On first focus, select the first datum. Subsequent focuses keep
652
+ the prior position (common keyboard-nav convention). */
653
+ if (this.#focusedDatumIdx === -1) this.#setFocusedDatum(0);
654
+ };
655
+
656
+ #onBlur = () => {
657
+ this.#clearFocusedDatum();
658
+ if (this.#hoveredTarget) this.#emitLeave();
659
+ };
660
+
661
+ #onKeydown = (e) => {
662
+ const datums = this.#datums();
663
+ if (!datums.length) return;
664
+ switch (e.key) {
665
+ case 'ArrowRight':
666
+ case 'ArrowDown':
667
+ e.preventDefault();
668
+ this.#setFocusedDatum(this.#focusedDatumIdx + 1);
669
+ break;
670
+ case 'ArrowLeft':
671
+ case 'ArrowUp':
672
+ e.preventDefault();
673
+ this.#setFocusedDatum(Math.max(0, this.#focusedDatumIdx - 1));
674
+ break;
675
+ case 'Home':
676
+ e.preventDefault();
677
+ this.#setFocusedDatum(0);
678
+ break;
679
+ case 'End':
680
+ e.preventDefault();
681
+ this.#setFocusedDatum(datums.length - 1);
682
+ break;
683
+ case 'Enter':
684
+ case ' ':
685
+ e.preventDefault();
686
+ this.#emitSelectForFocused();
687
+ break;
688
+ case 'Escape':
689
+ e.preventDefault();
690
+ this.#clearFocusedDatum();
691
+ if (this.#hoveredTarget) this.#emitLeave();
692
+ break;
693
+ }
694
+ };
695
+
696
+ #hoveredTarget = null;
697
+
375
698
  #onPointerOver = (e) => {
699
+ /* OD-CHART-07 — touch uses pointerdown to tap-to-pin; pointerover
700
+ is too noisy on touch devices (fires redundantly with down). */
701
+ if (e.pointerType === 'touch') return;
376
702
  const t = e.target.closest('[data-tip-label], [data-tip-value]');
377
- if (t) this.#showTooltip(t, e);
703
+ if (t) {
704
+ this.#showTooltip(t, e);
705
+ this.#emitHover(t, e);
706
+ }
378
707
  };
379
708
 
380
709
  #onPointerMove = (e) => {
710
+ if (e.pointerType === 'touch') return; /* touch handled via pointerdown */
381
711
  const t = e.target.closest('[data-tip-label], [data-tip-value]');
382
- if (!t) return this.#hideTooltip();
712
+ if (!t) {
713
+ if (this.#hoveredTarget) this.#emitLeave();
714
+ return this.#hideTooltip();
715
+ }
383
716
  this.#showTooltip(t, e);
717
+ if (t !== this.#hoveredTarget) this.#emitHover(t, e);
384
718
  };
385
719
 
386
- #onPointerLeave = () => this.#hideTooltip();
720
+ #onPointerLeave = (e) => {
721
+ if (e && e.pointerType === 'touch') return;
722
+ this.#hideTooltip();
723
+ if (this.#hoveredTarget) this.#emitLeave();
724
+ };
725
+
726
+ /* OD-CHART-07 — tap-to-pin on touch devices. Tapping a datum shows
727
+ the tooltip and emits chart-hover; tapping elsewhere (including
728
+ outside the chart via the document listener below) dismisses it.
729
+ Mouse + pen go through the existing pointerover path; pointerdown
730
+ here only processes touch so desktop behavior is unchanged. */
731
+ #onPointerDown = (e) => {
732
+ if (e.pointerType !== 'touch') return;
733
+ const t = e.target.closest('[data-tip-label], [data-tip-value]');
734
+ if (!t) {
735
+ /* Tap on empty plot area → dismiss any pinned tooltip. */
736
+ if (this.#hoveredTarget) this.#emitLeave();
737
+ return this.#hideTooltip();
738
+ }
739
+ this.#showTooltip(t, e);
740
+ if (t !== this.#hoveredTarget) this.#emitHover(t, e);
741
+ /* Attach document-level dismiss — removed when tap lands outside
742
+ the chart. Idempotent: re-attaching replaces the same listener
743
+ by reference. */
744
+ document.addEventListener('pointerdown', this.#pinnedTouchDismiss);
745
+ };
746
+
747
+ /* Document-level touch listener — dismiss pinned tooltip when the
748
+ user taps outside the chart. Only attached when a tooltip is
749
+ currently pinned via touch. Lazily attached/detached to avoid
750
+ perpetual document listeners on every page. */
751
+ #pinnedTouchDismiss = (e) => {
752
+ if (e.pointerType !== 'touch') return;
753
+ if (!this.contains(e.target)) {
754
+ this.#hideTooltip();
755
+ if (this.#hoveredTarget) this.#emitLeave();
756
+ document.removeEventListener('pointerdown', this.#pinnedTouchDismiss);
757
+ }
758
+ };
759
+
760
+ #onClick = (e) => {
761
+ const t = e.target.closest('[data-tip-label], [data-tip-value]');
762
+ if (!t) return;
763
+ this.dispatchEvent(new CustomEvent('chart-select', {
764
+ bubbles: true,
765
+ detail: this.#tipPayload(t, e),
766
+ }));
767
+ };
768
+
769
+ #emitHover(target, event) {
770
+ this.#hoveredTarget = target;
771
+ this.dispatchEvent(new CustomEvent('chart-hover', {
772
+ bubbles: true,
773
+ detail: this.#tipPayload(target, event),
774
+ }));
775
+ }
776
+
777
+ #emitLeave() {
778
+ this.#hoveredTarget = null;
779
+ this.dispatchEvent(new CustomEvent('chart-leave', { bubbles: true }));
780
+ }
781
+
782
+ /* Build the standard event payload from a hovered/clicked datum element.
783
+ OD-CHART-05 — per-X-column granularity. For multi-series renderers
784
+ (stacked-bar, grouped-bar, multi-line, composed), the pointer is
785
+ logically over "one X-axis column" and all series at that X are
786
+ hover-relevant. The detail therefore carries:
787
+ - top-level fields describing the specific datum the pointer
788
+ actually entered (label, value, pct, series, slot) — back-compat
789
+ with Phase 1-2 consumers.
790
+ - `payload` array with every series at this X column, each row
791
+ shaped { series, value, pct?, slot }. For single-series types,
792
+ payload has one entry containing the top-level datum.
793
+ Tooltip-ui[follows=pointer] renders one row per payload entry so
794
+ the card shows all series at that X, highlighting the hovered
795
+ series slightly. */
796
+ #tipPayload(target, event) {
797
+ const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;
798
+ const slot = target.dataset.slice != null ? Number(target.dataset.slice) : null;
799
+ const value = tipValue != null ? Number(tipValue) : null;
800
+ const pct = tipPct != null ? Number(tipPct) : null;
801
+
802
+ const hoveredLabel = tipLabel ?? null;
803
+ const hoveredSeries = tipSeries ?? null;
804
+
805
+ /* Build the per-X-column payload. Look up the data row whose x-key
806
+ matches the hovered label; enumerate declared y-keys; project a
807
+ row per (visible) series. For types without an x-key (pie/donut/
808
+ segments/radar — categorical) or with no y-keys, payload falls
809
+ back to a single entry matching the hovered datum. */
810
+ const payload = this.#buildXColumnPayload(hoveredLabel, hoveredSeries) ?? [{
811
+ series: hoveredSeries,
812
+ label: hoveredLabel,
813
+ value: Number.isFinite(value) ? value : (tipValue ?? null),
814
+ pct: Number.isFinite(pct) ? pct : null,
815
+ slot,
816
+ }];
817
+
818
+ return {
819
+ label: hoveredLabel,
820
+ value: Number.isFinite(value) ? value : (tipValue ?? null),
821
+ pct: Number.isFinite(pct) ? pct : null,
822
+ series: hoveredSeries,
823
+ slot,
824
+ payload,
825
+ pointerX: event?.clientX ?? null,
826
+ pointerY: event?.clientY ?? null,
827
+ };
828
+ }
829
+
830
+ #buildXColumnPayload(xLabel, hoveredSeries) {
831
+ if (xLabel == null) return null;
832
+ const xKey = this.x;
833
+ const yKeys = this.#yKeys();
834
+ if (!xKey || yKeys.length === 0) return null;
835
+ /* Find the row whose x-key value matches the hovered label. Raw
836
+ label string match — both sides came from the same source so no
837
+ type coercion needed. */
838
+ const row = this.#data.find(d => String(d[xKey] ?? '') === String(xLabel));
839
+ if (!row) return null;
840
+ const visible = yKeys.filter(k => !this.#isSeriesHidden(k));
841
+ /* Single-series types don't emit `data-tip-series` — the hovered
842
+ datum is implicitly the one and only series. Promote it so the
843
+ tooltip can still emphasize the row. */
844
+ const effectiveHovered = hoveredSeries || (visible.length === 1 ? visible[0] : null);
845
+ return visible.map((k, i) => {
846
+ const v = +(row[k] ?? 0);
847
+ return {
848
+ series: k,
849
+ label: xLabel,
850
+ value: Number.isFinite(v) ? v : null,
851
+ pct: null,
852
+ slot: i % 10,
853
+ hovered: k === effectiveHovered,
854
+ };
855
+ });
856
+ }
387
857
 
388
858
  #showTooltip(target, event) {
859
+ /* Phase 2 follow-up — when an external tooltip-ui[follows=pointer][for=self]
860
+ is present on the page, it owns the tooltip affordance. Skip the
861
+ internal #tipEl entirely to avoid double-render / fighting for the
862
+ top-layer. The external tooltip subscribes to chart-hover events
863
+ emitted in #emitHover below. */
864
+ if (this.#hasExternalTooltip()) return;
865
+
389
866
  const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;
390
867
 
391
868
  if (!this.#tipEl) {
@@ -402,21 +879,24 @@ class AdiaChart extends AdiaElement {
402
879
  if (tipLabel) lines.push(`<span data-tip-role="label">${esc(tipLabel)}</span>`);
403
880
  if (tipValue !== undefined) {
404
881
  const pct = tipPct !== undefined ? ` <span data-tip-role="pct">(${tipPct}%)</span>` : '';
405
- lines.push(`<span data-tip-role="value">${fmt(tipValue)}${pct}</span>`);
882
+ lines.push(`<span data-tip-role="value">${this.#fmtValue(tipValue)}${pct}</span>`);
406
883
  }
407
884
  this.#tipEl.innerHTML = lines.join('');
408
885
 
409
886
  try { this.#tipEl.showPopover(); } catch (_) { /* popover not supported */ }
410
887
 
411
- /* Follow the cursor — offset up-right, clamp to viewport */
888
+ /* Follow the cursor — centered horizontally above, clamp to viewport
889
+ with an 8px edge-pad, flip below when there's no room above. */
412
890
  const gap = 12;
891
+ const edgePad = 8;
413
892
  const { clientX, clientY } = event;
414
893
  const tw = this.#tipEl.offsetWidth || 0;
415
894
  const th = this.#tipEl.offsetHeight || 0;
416
- let x = clientX + gap;
895
+ let x = clientX - tw / 2;
417
896
  let y = clientY - th - gap;
418
- if (x + tw > window.innerWidth) x = clientX - tw - gap;
419
- if (y < 0) y = clientY + gap;
897
+ if (x < edgePad) x = edgePad;
898
+ if (x + tw > window.innerWidth - edgePad) x = window.innerWidth - tw - edgePad;
899
+ if (y < edgePad) y = clientY + gap;
420
900
  this.#tipEl.style.left = `${x}px`;
421
901
  this.#tipEl.style.top = `${y}px`;
422
902
  }
@@ -463,7 +943,7 @@ class AdiaChart extends AdiaElement {
463
943
  /* Y-axis labels */
464
944
  for (const t of displayTicks) {
465
945
  const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
466
- s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${fmt(t)}</text>`;
946
+ s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
467
947
  }
468
948
 
469
949
  /* X-axis labels — stride based on label width so they never overlap */
@@ -519,7 +999,7 @@ class AdiaChart extends AdiaElement {
519
999
  svg += `<rect data-bar${tip({ label: labels[i], value: v })} x="${bx}" y="${by}" width="${barInner}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
520
1000
 
521
1001
  if (showValues) {
522
- svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${fmt(v)}</text>`;
1002
+ svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
523
1003
  }
524
1004
  }
525
1005
 
@@ -527,7 +1007,7 @@ class AdiaChart extends AdiaElement {
527
1007
  const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
528
1008
  const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
529
1009
  svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
530
- svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${fmt(avg)}</text>`;
1010
+ svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
531
1011
  /* Wider invisible hit target so the thin dashed line is hoverable */
532
1012
  svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
533
1013
  }
@@ -575,7 +1055,7 @@ class AdiaChart extends AdiaElement {
575
1055
  svg += `<circle data-dot cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
576
1056
  svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
577
1057
  if (showValues) {
578
- svg += `<text data-value x="${p.x}" y="${p.y - 8}" text-anchor="middle" font-size="${dims.valueSize}">${fmt(p.v)}</text>`;
1058
+ svg += `<text data-value x="${p.x}" y="${p.y - 8}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(p.v)}</text>`;
579
1059
  }
580
1060
  }
581
1061
 
@@ -583,7 +1063,7 @@ class AdiaChart extends AdiaElement {
583
1063
  const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
584
1064
  const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
585
1065
  svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
586
- svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${fmt(avg)}</text>`;
1066
+ svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
587
1067
  /* Wider invisible hit target so the thin dashed line is hoverable */
588
1068
  svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
589
1069
  }
@@ -678,7 +1158,7 @@ class AdiaChart extends AdiaElement {
678
1158
  /* Center total — font size tied to donut radius so it scales with the chart */
679
1159
  const totalFs = Math.max(14, Math.round(outer * 0.32));
680
1160
  const labelFs = Math.max(9, Math.round(outer * 0.16));
681
- svg += `<text data-donut-total x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${fmt(total)}</text>`;
1161
+ svg += `<text data-donut-total x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(total)}</text>`;
682
1162
  svg += `<text data-donut-label x="${cx}" y="${cy + totalFs}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">Total</text>`;
683
1163
 
684
1164
  this.#legendData = data.map((d, i) => ({
@@ -815,6 +1295,537 @@ class AdiaChart extends AdiaElement {
815
1295
  return { svg, viewBox: `0 0 ${w} ${h}` };
816
1296
  }
817
1297
 
1298
+ /* ── Area chart (filled line) ────────────────────────────────────
1299
+ Single-series axis chart emphasizing the filled region under the
1300
+ curve. Shares all infrastructure with #renderLine() — CSS rules
1301
+ scoped to :scope[type="area"] override the area-fill opacity and
1302
+ the line treatment to make the region dominant. Output is DOM-
1303
+ compatible with #renderLine() so legend / tooltip / events work
1304
+ identically. */
1305
+ #renderArea() {
1306
+ return this.#renderLine();
1307
+ }
1308
+
1309
+ /* ── Scatter (points only, no connecting line) ──────────────────
1310
+ Two-dimensional distribution — each datum is a dot positioned by
1311
+ (x, y). Unlike #renderLine(), no connecting path is emitted. The
1312
+ x-key is typically also numeric; when it's a category label, the
1313
+ dots land at category-indexed column centers. */
1314
+ #renderScatter() {
1315
+ const dims = this.#dims();
1316
+ const data = this.#data;
1317
+ const yKey = this.#yKeys()[0] || this.y;
1318
+ const vals = data.map(v => +(v[yKey] ?? 0));
1319
+ const labels = data.map(v => v[this.x] ?? '');
1320
+ const ticks = niceScale(0, Math.max(...vals), 5);
1321
+ const maxVal = ticks[ticks.length - 1];
1322
+
1323
+ const { width, height, pad } = dims;
1324
+ const plotH = height - pad.top - pad.bottom;
1325
+ const plotW = width - pad.left - pad.right;
1326
+ const step = plotW / Math.max(data.length - 1, 1);
1327
+
1328
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1329
+
1330
+ const dotR = dims.sizeClass === 'sm' ? 2.5 : 4;
1331
+ const hitR = Math.max(dotR * 2, 10);
1332
+
1333
+ for (let i = 0; i < vals.length; i++) {
1334
+ const px = pad.left + step * i;
1335
+ const py = pad.top + plotH - (maxVal ? (vals[i] / maxVal) * plotH : 0);
1336
+ svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
1337
+ svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1338
+ }
1339
+
1340
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1341
+ }
1342
+
1343
+ /* ── Radial bar (concentric rings, each ring one datum) ─────────
1344
+ Each datum gets its own ring in a concentric stack. The ring's
1345
+ sweep angle is proportional to value/max — e.g., v=max is a full
1346
+ ring, v=0 is nothing. Rings are clipped to a common max-radius
1347
+ track shown as a faint backing arc so empty values read visually.
1348
+ Center is empty — authors who want a value overlay use the
1349
+ `[slot=empty]` trick or compose in a sibling element. */
1350
+ #renderRadialBar() {
1351
+ const data = this.#data;
1352
+ const yKey = this.#yKeys()[0] || this.y;
1353
+ const vals = data.map(d => +(d[yKey] ?? 0));
1354
+ const labels = data.map(d => d[this.x] ?? '');
1355
+ const maxVal = Math.max(...vals) || 1;
1356
+
1357
+ const dims = this.#dims();
1358
+ const { width, height } = dims;
1359
+ const cx = width / 2;
1360
+ const cy = height / 2;
1361
+ const outerR = Math.max(30, Math.min(width, height) * 0.45);
1362
+ const innerR = outerR * 0.3;
1363
+ const ringCount = vals.length || 1;
1364
+ const bandW = (outerR - innerR) / ringCount;
1365
+ const gap = Math.min(2, bandW * 0.15);
1366
+
1367
+ let svg = '';
1368
+
1369
+ /* Backing tracks + filled arcs per datum, from inner to outer.
1370
+ Uses the stroke-dasharray technique: each ring is a single <circle>,
1371
+ rotated so the dash starts at 12 o'clock, with dasharray sized to
1372
+ (filled, remainder). Avoids the "full circle" vs "arc path" branch
1373
+ that produced rendering artifacts at 100% fills. */
1374
+ for (let i = 0; i < vals.length; i++) {
1375
+ const r0 = innerR + bandW * i + gap / 2;
1376
+ const r1 = innerR + bandW * (i + 1) - gap / 2;
1377
+ const mid = (r0 + r1) / 2;
1378
+ const thickness = r1 - r0;
1379
+ const circumference = 2 * Math.PI * mid;
1380
+ const filled = Math.max(0, Math.min(1, vals[i] / maxVal)) * circumference;
1381
+
1382
+ /* Backing ring — full circle, faint stroke */
1383
+ svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;
1384
+
1385
+ if (filled <= 0) continue;
1386
+
1387
+ const pct = ((vals[i] / maxVal) * 100).toFixed(1);
1388
+ const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
1389
+
1390
+ /* Filled arc — stroke-dasharray splits the circumference into
1391
+ (drawn, gap). Rotated -90° around the center so the dash begins
1392
+ at 12 o'clock and sweeps clockwise. stroke-linecap="butt" for
1393
+ full rings (so the ends don't overlap into a wedge artifact);
1394
+ "round" for partial arcs so ends read as bar caps. */
1395
+ const isFull = Math.abs(filled - circumference) < 0.5;
1396
+ const linecap = isFull ? 'butt' : 'round';
1397
+ const dashArray = isFull
1398
+ ? `${circumference} 0`
1399
+ : `${filled} ${circumference - filled}`;
1400
+
1401
+ svg += `<circle data-slice="${i % 10}"${tipAttrs} data-radial-bar cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}" stroke-linecap="${linecap}" stroke-dasharray="${dashArray}" transform="rotate(-90 ${cx} ${cy})"/>`;
1402
+ }
1403
+
1404
+ this.#legendData = data.map((d, i) => ({
1405
+ label: labels[i],
1406
+ key: labels[i],
1407
+ value: vals[i],
1408
+ pct: ((vals[i] / maxVal) * 100).toFixed(1),
1409
+ slot: i % 10,
1410
+ }));
1411
+
1412
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1413
+ }
1414
+
1415
+ /* ── Gauge (half-donut with center value) ───────────────────────
1416
+ Common KPI visualization: a 180° arc filled from the leftmost
1417
+ point (9 o'clock) clockwise to the current value. Center shows
1418
+ the value as a large number. Uses the same data-slice fill path
1419
+ as pie/donut for theme coherence. Accepts a single datum OR
1420
+ sums all data values — max is `maxVal` prop if set, otherwise
1421
+ taken as the sum of all values (treats the total as 100%). */
1422
+ #renderGauge() {
1423
+ const data = this.#data;
1424
+ const yKey = this.#yKeys()[0] || this.y;
1425
+ const vals = data.map(d => +(d[yKey] ?? 0));
1426
+ const sum = vals.reduce((a, b) => a + b, 0) || 1;
1427
+
1428
+ /* Gauge reads a single value + optional max from the first datum
1429
+ (v, max?) OR treats the first value as the numerator and the
1430
+ sum as the denominator for "X of Y" style metrics. */
1431
+ const primary = vals[0] ?? 0;
1432
+ const maxVal = data[0]?.max != null ? +data[0].max : (vals.length === 1 ? Math.max(primary, 1) : sum);
1433
+ const pct = Math.max(0, Math.min(1, primary / maxVal));
1434
+
1435
+ const dims = this.#dims();
1436
+ const { width, height } = dims;
1437
+
1438
+ /* Place the arc's visual center so the half-circle uses the full
1439
+ width below the value label. cy sits lower so the half-arc has
1440
+ room for the big center label above it. */
1441
+ const cx = width / 2;
1442
+ const cy = height * 0.68;
1443
+ const outerR = Math.max(40, Math.min(width * 0.45, height * 0.6));
1444
+ const innerR = outerR * 0.72;
1445
+
1446
+ let svg = '';
1447
+
1448
+ /* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
1449
+ clockwise through 12 o'clock (3π/2) to 3 o'clock (2π). donutArcPath
1450
+ produces a filled ring wedge; with start=π, end=2π the sliceAngle
1451
+ equals π so large-arc-flag=0 and the arc passes over the top. */
1452
+ svg += `<path data-radial-track d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, 2 * Math.PI, 0)}"/>`;
1453
+
1454
+ /* Filled portion — 0..180° proportional to pct. pct=0 draws nothing;
1455
+ pct=1 overlays the full backing arc. */
1456
+ if (pct > 0) {
1457
+ const fillEnd = Math.PI + Math.PI * pct;
1458
+ const tipAttrs = tip({ label: data[0]?.[this.x] ?? 'Value', value: primary, pct: (pct * 100).toFixed(1) });
1459
+ svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, 0)}"/>`;
1460
+ }
1461
+
1462
+ /* Center value label */
1463
+ const totalFs = Math.max(18, Math.round(outerR * 0.42));
1464
+ const labelFs = Math.max(10, Math.round(outerR * 0.2));
1465
+ const labelY = cy - outerR * 0.15;
1466
+ svg += `<text data-gauge-value x="${cx}" y="${labelY}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
1467
+ if (maxVal !== primary) {
1468
+ svg += `<text data-gauge-max x="${cx}" y="${labelY + totalFs * 0.8}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">of ${this.#fmtValue(maxVal)}</text>`;
1469
+ }
1470
+
1471
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1472
+ }
1473
+
1474
+ /* ── Funnel (stage drop-off) ───────────────────────────────────
1475
+ Classic conversion funnel: stages listed top-to-bottom, each
1476
+ rendered as a trapezoid whose width shrinks proportional to its
1477
+ value. Stage labels live to the left, value + pct vs first stage
1478
+ to the right. Typical use: sales pipeline, signup funnel.
1479
+
1480
+ Trapezoid geometry: for stage i with value v_i and max value v_0:
1481
+ halfWidthTop = (v_i / v_max) * plotW / 2
1482
+ halfWidthBot = (v_{i+1} / v_max) * plotW / 2
1483
+ Last stage uses halfWidthBot = halfWidthTop (degrades to rect). */
1484
+ #renderFunnel() {
1485
+ const data = this.#data;
1486
+ const yKey = this.#yKeys()[0] || this.y;
1487
+ const vals = data.map(d => +(d[yKey] ?? 0));
1488
+ const labels = data.map(d => d[this.x] ?? '');
1489
+ const n = vals.length;
1490
+ if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
1491
+
1492
+ const maxVal = Math.max(...vals) || 1;
1493
+
1494
+ const dims = this.#dims();
1495
+ const { width, height } = dims;
1496
+ const fs = dims.fontSize;
1497
+ const padX = Math.max(width * 0.18, 80); /* room for stage labels */
1498
+ const padY = fs * 0.8;
1499
+ const plotW = width - padX * 2;
1500
+ const plotH = height - padY * 2;
1501
+ const stageH = plotH / n;
1502
+ const gap = Math.max(2, stageH * 0.08);
1503
+
1504
+ const cx = width / 2;
1505
+
1506
+ let svg = '';
1507
+ for (let i = 0; i < n; i++) {
1508
+ const vTop = vals[i];
1509
+ const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
1510
+ const halfTop = (vTop / maxVal) * (plotW / 2);
1511
+ const halfBot = (vBot / maxVal) * (plotW / 2);
1512
+ const y0 = padY + stageH * i + gap / 2;
1513
+ const y1 = padY + stageH * (i + 1) - gap / 2;
1514
+
1515
+ const pct = ((vTop / maxVal) * 100).toFixed(1);
1516
+ const tipAttrs = tip({ label: labels[i], value: vTop, pct });
1517
+
1518
+ /* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
1519
+ const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
1520
+ svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
1521
+
1522
+ /* Stage label — left-aligned outside the funnel */
1523
+ svg += `<text data-funnel-label x="${padX - 8}" y="${y0 + stageH / 2}" text-anchor="end" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
1524
+
1525
+ /* Value + pct — right-aligned outside the funnel */
1526
+ svg += `<text data-funnel-value x="${width - padX + 8}" y="${y0 + stageH / 2}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
1527
+ if (i > 0) {
1528
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
1529
+ svg += `<text data-funnel-drop x="${width - padX + 8}" y="${y0 + stageH / 2 + fs * 1.1}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
1530
+ }
1531
+ }
1532
+
1533
+ this.#legendData = data.map((d, i) => ({
1534
+ label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
1535
+ }));
1536
+
1537
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1538
+ }
1539
+
1540
+ /* ── Treemap (hierarchical rect tiling) ────────────────────────
1541
+ Flat squarified treemap: each datum is a rect whose area is
1542
+ proportional to its value. Not nested — Phase 5 ships flat
1543
+ rectangles; a nested variant (children arrays) is a natural
1544
+ future extension.
1545
+
1546
+ Uses the Bruls/Huijbregts/Van Wijk squarified algorithm adapted
1547
+ for single-level data: sorts values desc, packs rows that hold
1548
+ aspect ratios close to 1, alternating row direction based on
1549
+ remaining container aspect. */
1550
+ #renderTreemap() {
1551
+ const data = this.#data;
1552
+ const yKey = this.#yKeys()[0] || this.y;
1553
+ const vals = data.map(d => +(d[yKey] ?? 0));
1554
+ const labels = data.map(d => d[this.x] ?? '');
1555
+ const n = vals.length;
1556
+ if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
1557
+
1558
+ const dims = this.#dims();
1559
+ const { width, height, fontSize } = dims;
1560
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
1561
+
1562
+ /* Sort indices by value desc — squarified needs sorted input but
1563
+ we keep original indices for color/label/hit mapping. */
1564
+ const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);
1565
+
1566
+ const rects = []; /* {i, x, y, w, h} in plot coords */
1567
+
1568
+ /* Scale areas so they fill the container. */
1569
+ const area = width * height;
1570
+ const scaled = order.map(i => (vals[i] / total) * area);
1571
+
1572
+ /* Squarified packer — iterative rows. */
1573
+ let x = 0, y = 0, w = width, h = height;
1574
+ let row = [];
1575
+ let rowStart = 0;
1576
+
1577
+ const worst = (row, width) => {
1578
+ if (row.length === 0) return Infinity;
1579
+ const sum = row.reduce((a, b) => a + b, 0);
1580
+ const max = Math.max(...row);
1581
+ const min = Math.min(...row);
1582
+ const wsq = width * width;
1583
+ const ssq = sum * sum;
1584
+ return Math.max((wsq * max) / ssq, ssq / (wsq * min));
1585
+ };
1586
+
1587
+ const layoutRow = (row, rowStart, x, y, w, h) => {
1588
+ const sum = row.reduce((a, b) => a + b, 0);
1589
+ const horizontal = w >= h;
1590
+ const rowH = horizontal ? sum / w : h;
1591
+ const rowW = horizontal ? w : sum / h;
1592
+
1593
+ let offset = 0;
1594
+ for (let k = 0; k < row.length; k++) {
1595
+ const frac = row[k] / sum;
1596
+ const idx = order[rowStart + k];
1597
+ if (horizontal) {
1598
+ const segW = frac * w;
1599
+ rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
1600
+ offset += segW;
1601
+ } else {
1602
+ const segH = frac * h;
1603
+ rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
1604
+ offset += segH;
1605
+ }
1606
+ }
1607
+ if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
1608
+ return { x: x + rowW, y, w: w - rowW, h };
1609
+ };
1610
+
1611
+ let remaining = scaled.slice();
1612
+ while (remaining.length > 0) {
1613
+ row = [remaining[0]];
1614
+ rowStart = scaled.length - remaining.length;
1615
+ const shortSide = Math.min(w, h);
1616
+ let i = 1;
1617
+ while (i < remaining.length) {
1618
+ const next = row.concat(remaining[i]);
1619
+ if (worst(next, shortSide) > worst(row, shortSide)) break;
1620
+ row = next;
1621
+ i++;
1622
+ }
1623
+ const newRect = layoutRow(row, rowStart, x, y, w, h);
1624
+ x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
1625
+ remaining = remaining.slice(row.length);
1626
+ }
1627
+
1628
+ /* Emit rects + labels. */
1629
+ let svg = '';
1630
+ const pad = 2;
1631
+ for (const r of rects) {
1632
+ const pct = ((vals[r.i] / total) * 100).toFixed(1);
1633
+ const tipAttrs = tip({ label: labels[r.i], value: vals[r.i], pct });
1634
+ svg += `<rect data-slice="${r.i % 10}" data-treemap-tile${tipAttrs} x="${r.x + pad}" y="${r.y + pad}" width="${Math.max(0, r.w - pad * 2)}" height="${Math.max(0, r.h - pad * 2)}" rx="${this.#resolveRadius()}"/>`;
1635
+
1636
+ /* Three text-placement branches keyed on available tile size:
1637
+ - `tall` (h > ~2.5 line heights): label + value stacked, top-aligned
1638
+ - `short` (h ≥ ~1.2 line heights but < tall): label only, vertically
1639
+ centered — avoids the clipped-text look when tiles are squat
1640
+ - `tiny` (h too small for even one line): no text
1641
+ All branches gate on width > ~4× fontSize so labels don't overflow. */
1642
+ const labelX = r.x + 8;
1643
+ const canShowAny = r.w > fontSize * 4;
1644
+ const tall = canShowAny && r.h > fontSize * 2.5;
1645
+ const short = canShowAny && !tall && r.h > fontSize * 1.2;
1646
+
1647
+ if (tall) {
1648
+ svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(labels[r.i])}</text>`;
1649
+ svg += `<text data-treemap-value x="${labelX}" y="${r.y + fontSize * 2 + 6}" font-size="${fontSize * 0.9}" dominant-baseline="hanging">${this.#fmtValue(vals[r.i])}</text>`;
1650
+ } else if (short) {
1651
+ const cy = r.y + r.h / 2;
1652
+ svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(labels[r.i])}</text>`;
1653
+ }
1654
+ }
1655
+
1656
+ this.#legendData = data.map((d, i) => ({
1657
+ label: labels[i], value: vals[i], pct: ((vals[i] / total) * 100).toFixed(1), slot: i % 10,
1658
+ }));
1659
+
1660
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1661
+ }
1662
+
1663
+ /* ── Sankey (flow between source and target nodes) ─────────────
1664
+ Flow diagram with columns of nodes and curved bands connecting
1665
+ them. Data shape for Sankey differs from other chart types:
1666
+ data = [{ source: 'A', target: 'B', value: 10 }, ...]
1667
+ Nodes are auto-derived from unique source/target values.
1668
+ Lays out two columns (source-left, target-right) with node
1669
+ heights proportional to throughput. */
1670
+ #renderSankey() {
1671
+ const data = this.#data;
1672
+ if (!data.length) return { svg: '', viewBox: '0 0 100 100' };
1673
+
1674
+ /* Derive source + target node sets from the link data. */
1675
+ const sourceSet = new Map(); /* name → { outflow, y0, y1 } */
1676
+ const targetSet = new Map(); /* name → { inflow, y0, y1 } */
1677
+ for (const link of data) {
1678
+ const s = link.source ?? link.from ?? '';
1679
+ const t = link.target ?? link.to ?? '';
1680
+ const v = +(link.value ?? link.v ?? 0);
1681
+ if (!sourceSet.has(s)) sourceSet.set(s, { name: s, flow: 0 });
1682
+ if (!targetSet.has(t)) targetSet.set(t, { name: t, flow: 0 });
1683
+ sourceSet.get(s).flow += v;
1684
+ targetSet.get(t).flow += v;
1685
+ }
1686
+
1687
+ const dims = this.#dims();
1688
+ const { width, height, fontSize } = dims;
1689
+ const nodeW = Math.max(8, width * 0.03);
1690
+ const colPad = nodeW + fontSize * 5;
1691
+
1692
+ const sourceTotal = [...sourceSet.values()].reduce((a, s) => a + s.flow, 0) || 1;
1693
+ const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
1694
+
1695
+ const nodeGap = fontSize * 0.6;
1696
+
1697
+ /* Assign y0/y1 to each source node, stacked top-to-bottom. */
1698
+ const sources = [...sourceSet.values()];
1699
+ const targets = [...targetSet.values()];
1700
+ const sourceTotalH = height - nodeGap * (sources.length - 1);
1701
+ const targetTotalH = height - nodeGap * (targets.length - 1);
1702
+
1703
+ let y = 0;
1704
+ for (const s of sources) {
1705
+ const h = (s.flow / sourceTotal) * sourceTotalH;
1706
+ s.y0 = y;
1707
+ s.y1 = y + h;
1708
+ s.cursor = y; /* running y for outgoing links */
1709
+ y += h + nodeGap;
1710
+ }
1711
+ y = 0;
1712
+ for (const t of targets) {
1713
+ const h = (t.flow / targetTotal) * targetTotalH;
1714
+ t.y0 = y;
1715
+ t.y1 = y + h;
1716
+ t.cursor = y;
1717
+ y += h + nodeGap;
1718
+ }
1719
+
1720
+ let svg = '';
1721
+
1722
+ const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);
1723
+
1724
+ /* Source nodes — left column */
1725
+ sources.forEach((s, i) => {
1726
+ svg += `<rect data-sankey-node data-slice="${i % 10}" x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
1727
+ svg += `<text data-sankey-label x="${colPad - nodeW - 6}" y="${(s.y0 + s.y1) / 2}" text-anchor="end" dominant-baseline="central" font-size="${fontSize}">${esc(s.name)}</text>`;
1728
+ });
1729
+
1730
+ /* Target nodes — right column */
1731
+ targets.forEach((t, i) => {
1732
+ svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}" x="${width - colPad}" y="${t.y0}" width="${nodeW}" height="${t.y1 - t.y0}" rx="${nodeRx}"/>`;
1733
+ svg += `<text data-sankey-label x="${width - colPad + nodeW + 6}" y="${(t.y0 + t.y1) / 2}" text-anchor="start" dominant-baseline="central" font-size="${fontSize}">${esc(t.name)}</text>`;
1734
+ });
1735
+
1736
+ /* Links — bezier bands */
1737
+ for (const link of data) {
1738
+ const s = sourceSet.get(link.source ?? link.from ?? '');
1739
+ const t = targetSet.get(link.target ?? link.to ?? '');
1740
+ if (!s || !t) continue;
1741
+ const v = +(link.value ?? link.v ?? 0);
1742
+ if (v <= 0) continue;
1743
+
1744
+ const sH = (v / sourceTotal) * sourceTotalH;
1745
+ const tH = (v / targetTotal) * targetTotalH;
1746
+ const sTop = s.cursor;
1747
+ const sBot = sTop + sH;
1748
+ const tTop = t.cursor;
1749
+ const tBot = tTop + tH;
1750
+ s.cursor += sH;
1751
+ t.cursor += tH;
1752
+
1753
+ const x0 = colPad;
1754
+ const x1 = width - colPad;
1755
+ const mx = (x0 + x1) / 2;
1756
+ const tipAttrs = tip({ label: `${s.name} → ${t.name}`, value: v });
1757
+
1758
+ const path = `M ${x0} ${sTop} C ${mx} ${sTop}, ${mx} ${tTop}, ${x1} ${tTop} L ${x1} ${tBot} C ${mx} ${tBot}, ${mx} ${sBot}, ${x0} ${sBot} Z`;
1759
+ svg += `<path data-sankey-link${tipAttrs} d="${path}"/>`;
1760
+ }
1761
+
1762
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1763
+ }
1764
+
1765
+ /* ── Composed (bar + line overlay) ─────────────────────────────
1766
+ Multi-series combining a bar chart for primary values with a line
1767
+ overlay for secondary values. Data shape: each row has the x-key,
1768
+ a `bar` key, and a `line` key. Both are axis-aligned against the
1769
+ same Y scale (single axis for v1; dual-axis future extension).
1770
+
1771
+ Uses y="bar,line" as the series keys by convention. */
1772
+ #renderComposed() {
1773
+ const dims = this.#dims();
1774
+ const data = this.#data;
1775
+ const keys = this.#yKeys();
1776
+ const barKey = keys[0] || 'bar';
1777
+ const lineKey = keys[1] || 'line';
1778
+ const labels = data.map(d => d[this.x] ?? '');
1779
+ const barVals = data.map(d => +(d[barKey] ?? 0));
1780
+ const lineVals = data.map(d => +(d[lineKey] ?? 0));
1781
+ const allVals = [...barVals, ...lineVals];
1782
+ const ticks = niceScale(0, Math.max(...allVals), 5);
1783
+ const maxVal = ticks[ticks.length - 1];
1784
+
1785
+ const { width, height, pad } = dims;
1786
+ const plotH = height - pad.top - pad.bottom;
1787
+ const plotW = width - pad.left - pad.right;
1788
+ const barW = plotW / data.length;
1789
+ const barInner = barW * 0.6;
1790
+ const barGap = (barW - barInner) / 2;
1791
+ const step = plotW / Math.max(data.length - 1, 1);
1792
+
1793
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1794
+
1795
+ /* Bar series (slot 0) */
1796
+ if (!this.#isSeriesHidden(barKey)) {
1797
+ for (let i = 0; i < data.length; i++) {
1798
+ const v = barVals[i];
1799
+ const barH = maxVal ? (v / maxVal) * plotH : 0;
1800
+ const bx = pad.left + barW * i + barGap;
1801
+ const by = pad.top + plotH - barH;
1802
+ svg += `<rect${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} x="${bx}" y="${by}" width="${barInner}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
1803
+ }
1804
+ }
1805
+
1806
+ /* Line series (slot 1) — anchored at bar center X positions */
1807
+ if (!this.#isSeriesHidden(lineKey)) {
1808
+ const points = lineVals.map((v, i) => ({
1809
+ x: pad.left + barW * i + barW / 2,
1810
+ y: pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0),
1811
+ v, label: labels[i],
1812
+ }));
1813
+ const t = Math.max(0, Math.min(1, this.smooth));
1814
+ svg += `<path data-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(points, t)}"/>`;
1815
+ for (const p of points) {
1816
+ svg += `<circle data-dot${this.#seriesFill(1, lineKey)} cx="${p.x}" cy="${p.y}" r="3"/>`;
1817
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
1818
+ }
1819
+ }
1820
+
1821
+ this.#legendData = [
1822
+ { label: barKey, key: barKey, slot: 0 },
1823
+ { label: lineKey, key: lineKey, slot: 1 },
1824
+ ];
1825
+
1826
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1827
+ }
1828
+
818
1829
  /* ── Segments (single horizontal stacked bar) ──────────────────
819
1830
  Categorical data rendered as one horizontal bar split into
820
1831
  proportional colored slices. Good for compact in-card
@@ -915,6 +1926,7 @@ class AdiaChart extends AdiaElement {
915
1926
  let stackY = pad.top + plotH;
916
1927
  const segCount = keys.length;
917
1928
  for (let k = 0; k < segCount; k++) {
1929
+ if (this.#isSeriesHidden(keys[k])) continue;
918
1930
  const v = +(data[i][keys[k]] ?? 0);
919
1931
  const segH = maxVal ? (v / maxVal) * plotH : 0;
920
1932
  if (segH <= 0) { stackY -= segH; continue; }
@@ -926,7 +1938,7 @@ class AdiaChart extends AdiaElement {
926
1938
  const isBottom = k === 0;
927
1939
  const r = Math.min(this.#resolveRadius(), barInner / 2, bh / 2);
928
1940
 
929
- const attrs = ` data-slice="${k % 10}"${tip({ label: labels[i], value: v, series: keys[k] })}`;
1941
+ const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
930
1942
 
931
1943
  if (isTop && isBottom) {
932
1944
  // Single segment — round top + bottom
@@ -944,7 +1956,7 @@ class AdiaChart extends AdiaElement {
944
1956
  }
945
1957
  }
946
1958
 
947
- this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
1959
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
948
1960
 
949
1961
  return { svg, viewBox: `0 0 ${width} ${height}` };
950
1962
  }
@@ -974,19 +1986,20 @@ class AdiaChart extends AdiaElement {
974
1986
 
975
1987
  for (let i = 0; i < data.length; i++) {
976
1988
  for (let k = 0; k < keys.length; k++) {
1989
+ if (this.#isSeriesHidden(keys[k])) continue;
977
1990
  const v = +(data[i][keys[k]] ?? 0);
978
1991
  const barH = maxVal ? (v / maxVal) * plotH : 0;
979
1992
  const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
980
1993
  const by = pad.top + plotH - barH;
981
- svg += `<rect data-slice="${k % 10}"${tip({ label: labels[i], value: v, series: keys[k] })} x="${bx}" y="${by}" width="${subBarW}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
1994
+ svg += `<rect${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} x="${bx}" y="${by}" width="${subBarW}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
982
1995
 
983
1996
  if (!this.hideValues) {
984
- svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${fmt(v)}</text>`;
1997
+ svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
985
1998
  }
986
1999
  }
987
2000
  }
988
2001
 
989
- this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
2002
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
990
2003
 
991
2004
  return { svg, viewBox: `0 0 ${width} ${height}` };
992
2005
  }
@@ -1011,6 +2024,7 @@ class AdiaChart extends AdiaElement {
1011
2024
  let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1012
2025
 
1013
2026
  for (let k = 0; k < keys.length; k++) {
2027
+ if (this.#isSeriesHidden(keys[k])) continue;
1014
2028
  const vals = data.map(d => +(d[keys[k]] ?? 0));
1015
2029
  const points = vals.map((v, i) => {
1016
2030
  const px = pad.left + step * i;
@@ -1022,20 +2036,20 @@ class AdiaChart extends AdiaElement {
1022
2036
  const t = Math.max(0, Math.min(1, this.smooth));
1023
2037
 
1024
2038
  /* Area fill */
1025
- svg += `<path data-area data-slice="${k % 10}" d="${smoothAreaPath(points, baseline, t)}"/>`;
2039
+ svg += `<path data-area${this.#seriesFill(k % 10, keys[k])} d="${smoothAreaPath(points, baseline, t)}"/>`;
1026
2040
 
1027
2041
  /* Line */
1028
- svg += `<path data-line data-slice="${k % 10}" d="${smoothPath(points, t)}"/>`;
2042
+ svg += `<path data-line${this.#seriesStroke(k % 10, keys[k])} d="${smoothPath(points, t)}"/>`;
1029
2043
 
1030
2044
  /* Dots + hit targets. Hit circles deliberately omit data-slice so
1031
2045
  they aren't caught by the circle[data-slice] fill rule in CSS. */
1032
2046
  for (const p of points) {
1033
- svg += `<circle data-dot data-slice="${k % 10}" cx="${p.x}" cy="${p.y}" r="3"/>`;
2047
+ svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])} cx="${p.x}" cy="${p.y}" r="3"/>`;
1034
2048
  svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
1035
2049
  }
1036
2050
  }
1037
2051
 
1038
- this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
2052
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
1039
2053
 
1040
2054
  return { svg, viewBox: `0 0 ${width} ${height}` };
1041
2055
  }
@@ -1044,6 +2058,14 @@ class AdiaChart extends AdiaElement {
1044
2058
 
1045
2059
  #legendData = null;
1046
2060
 
2061
+ /* Public getter — external consumers (chart-legend-ui[for]) read this to
2062
+ mirror series data. Returns a defensive shallow copy so callers can't
2063
+ mutate our internals. Null when the chart has no legend-bearing type
2064
+ or hasn't rendered yet. */
2065
+ get legendData() {
2066
+ return this.#legendData ? this.#legendData.map(it => ({ ...it })) : null;
2067
+ }
2068
+
1047
2069
  #buildLegend() {
1048
2070
  if (!this.#legendData || !this.#legendData.length) return null;
1049
2071
 
@@ -1053,10 +2075,12 @@ class AdiaChart extends AdiaElement {
1053
2075
  for (const item of this.#legendData) {
1054
2076
  const el = document.createElement('span');
1055
2077
  el.setAttribute('data-legend-item', '');
2078
+ if (item.key) el.setAttribute('data-series-key', item.key);
1056
2079
 
1057
2080
  const dot = document.createElement('span');
1058
2081
  dot.setAttribute('data-legend-dot', '');
1059
2082
  dot.setAttribute('data-slice', String(item.slot));
2083
+ if (item.key) dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
1060
2084
  el.appendChild(dot);
1061
2085
 
1062
2086
  const text = document.createElement('span');
@@ -1066,7 +2090,8 @@ class AdiaChart extends AdiaElement {
1066
2090
  legend.appendChild(el);
1067
2091
  }
1068
2092
 
1069
- this.#legendData = null;
2093
+ /* Intentionally DO NOT null #legendData here — public `legendData`
2094
+ getter needs it to survive so chart-legend-ui[for] can mirror. */
1070
2095
  return legend;
1071
2096
  }
1072
2097
  }