@adia-ai/web-components 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +10 -10
  2. package/components/card/card.css +29 -0
  3. package/components/chart/chart.a2ui.json +43 -6
  4. package/components/chart/chart.css +224 -0
  5. package/components/chart/chart.js +1049 -27
  6. package/components/chart/chart.yaml +62 -6
  7. package/components/chart-legend/chart-legend.a2ui.json +139 -0
  8. package/components/chart-legend/chart-legend.css +124 -0
  9. package/components/chart-legend/chart-legend.js +185 -0
  10. package/components/chart-legend/chart-legend.yaml +133 -0
  11. package/components/code/code-editor.js +161 -0
  12. package/components/code/code.a2ui.json +59 -0
  13. package/components/code/code.css +78 -2
  14. package/components/code/code.js +147 -9
  15. package/components/code/code.yaml +42 -0
  16. package/components/heatmap/heatmap.js +62 -13
  17. package/components/index.js +1 -0
  18. package/components/select/select.css +1 -1
  19. package/components/slider/slider.js +8 -3
  20. package/components/stat/stat.a2ui.json +3 -0
  21. package/components/stat/stat.css +32 -0
  22. package/components/stat/stat.yaml +6 -0
  23. package/components/tooltip/tooltip.a2ui.json +29 -4
  24. package/components/tooltip/tooltip.css +111 -0
  25. package/components/tooltip/tooltip.js +200 -12
  26. package/components/tooltip/tooltip.yaml +38 -4
  27. package/core/icons.js +35 -1
  28. package/core/index.js +25 -0
  29. package/core/provider.js +1 -1
  30. package/index.css +26 -0
  31. package/index.js +18 -0
  32. package/package.json +14 -6
  33. package/patterns/adia-chat/adia-chat.js +1 -1
  34. package/styles/colors/semantics.css +6 -5
  35. package/styles/{styles.css → components.css} +9 -111
  36. package/styles/resets.css +116 -0
  37. package/styles/tokens.css +8 -2
  38. package/core/_cm-core.js +0 -38
  39. package/core/_cm-theme.js +0 -58
  40. package/core/_lang-css.js +0 -2
  41. package/core/_lang-html.js +0 -2
  42. package/core/_lang-javascript.js +0 -2
  43. package/core/_lang-json.js +0 -2
  44. package/core/_lang-markdown.js +0 -2
  45. package/core/_lang-yaml.js +0 -2
  46. package/core/code-editor-bundle.js +0 -63
@@ -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,7 +879,7 @@ 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
 
@@ -463,7 +940,7 @@ class AdiaChart extends AdiaElement {
463
940
  /* Y-axis labels */
464
941
  for (const t of displayTicks) {
465
942
  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>`;
943
+ 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
944
  }
468
945
 
469
946
  /* X-axis labels — stride based on label width so they never overlap */
@@ -519,7 +996,7 @@ class AdiaChart extends AdiaElement {
519
996
  svg += `<rect data-bar${tip({ label: labels[i], value: v })} x="${bx}" y="${by}" width="${barInner}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
520
997
 
521
998
  if (showValues) {
522
- svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${fmt(v)}</text>`;
999
+ svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
523
1000
  }
524
1001
  }
525
1002
 
@@ -527,7 +1004,7 @@ class AdiaChart extends AdiaElement {
527
1004
  const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
528
1005
  const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
529
1006
  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>`;
1007
+ 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
1008
  /* Wider invisible hit target so the thin dashed line is hoverable */
532
1009
  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
1010
  }
@@ -575,7 +1052,7 @@ class AdiaChart extends AdiaElement {
575
1052
  svg += `<circle data-dot cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
576
1053
  svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
577
1054
  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>`;
1055
+ svg += `<text data-value x="${p.x}" y="${p.y - 8}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(p.v)}</text>`;
579
1056
  }
580
1057
  }
581
1058
 
@@ -583,7 +1060,7 @@ class AdiaChart extends AdiaElement {
583
1060
  const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
584
1061
  const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
585
1062
  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>`;
1063
+ 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
1064
  /* Wider invisible hit target so the thin dashed line is hoverable */
588
1065
  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
1066
  }
@@ -678,7 +1155,7 @@ class AdiaChart extends AdiaElement {
678
1155
  /* Center total — font size tied to donut radius so it scales with the chart */
679
1156
  const totalFs = Math.max(14, Math.round(outer * 0.32));
680
1157
  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>`;
1158
+ svg += `<text data-donut-total x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(total)}</text>`;
682
1159
  svg += `<text data-donut-label x="${cx}" y="${cy + totalFs}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">Total</text>`;
683
1160
 
684
1161
  this.#legendData = data.map((d, i) => ({
@@ -815,6 +1292,537 @@ class AdiaChart extends AdiaElement {
815
1292
  return { svg, viewBox: `0 0 ${w} ${h}` };
816
1293
  }
817
1294
 
1295
+ /* ── Area chart (filled line) ────────────────────────────────────
1296
+ Single-series axis chart emphasizing the filled region under the
1297
+ curve. Shares all infrastructure with #renderLine() — CSS rules
1298
+ scoped to :scope[type="area"] override the area-fill opacity and
1299
+ the line treatment to make the region dominant. Output is DOM-
1300
+ compatible with #renderLine() so legend / tooltip / events work
1301
+ identically. */
1302
+ #renderArea() {
1303
+ return this.#renderLine();
1304
+ }
1305
+
1306
+ /* ── Scatter (points only, no connecting line) ──────────────────
1307
+ Two-dimensional distribution — each datum is a dot positioned by
1308
+ (x, y). Unlike #renderLine(), no connecting path is emitted. The
1309
+ x-key is typically also numeric; when it's a category label, the
1310
+ dots land at category-indexed column centers. */
1311
+ #renderScatter() {
1312
+ const dims = this.#dims();
1313
+ const data = this.#data;
1314
+ const yKey = this.#yKeys()[0] || this.y;
1315
+ const vals = data.map(v => +(v[yKey] ?? 0));
1316
+ const labels = data.map(v => v[this.x] ?? '');
1317
+ const ticks = niceScale(0, Math.max(...vals), 5);
1318
+ const maxVal = ticks[ticks.length - 1];
1319
+
1320
+ const { width, height, pad } = dims;
1321
+ const plotH = height - pad.top - pad.bottom;
1322
+ const plotW = width - pad.left - pad.right;
1323
+ const step = plotW / Math.max(data.length - 1, 1);
1324
+
1325
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1326
+
1327
+ const dotR = dims.sizeClass === 'sm' ? 2.5 : 4;
1328
+ const hitR = Math.max(dotR * 2, 10);
1329
+
1330
+ for (let i = 0; i < vals.length; i++) {
1331
+ const px = pad.left + step * i;
1332
+ const py = pad.top + plotH - (maxVal ? (vals[i] / maxVal) * plotH : 0);
1333
+ svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
1334
+ svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1335
+ }
1336
+
1337
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1338
+ }
1339
+
1340
+ /* ── Radial bar (concentric rings, each ring one datum) ─────────
1341
+ Each datum gets its own ring in a concentric stack. The ring's
1342
+ sweep angle is proportional to value/max — e.g., v=max is a full
1343
+ ring, v=0 is nothing. Rings are clipped to a common max-radius
1344
+ track shown as a faint backing arc so empty values read visually.
1345
+ Center is empty — authors who want a value overlay use the
1346
+ `[slot=empty]` trick or compose in a sibling element. */
1347
+ #renderRadialBar() {
1348
+ const data = this.#data;
1349
+ const yKey = this.#yKeys()[0] || this.y;
1350
+ const vals = data.map(d => +(d[yKey] ?? 0));
1351
+ const labels = data.map(d => d[this.x] ?? '');
1352
+ const maxVal = Math.max(...vals) || 1;
1353
+
1354
+ const dims = this.#dims();
1355
+ const { width, height } = dims;
1356
+ const cx = width / 2;
1357
+ const cy = height / 2;
1358
+ const outerR = Math.max(30, Math.min(width, height) * 0.45);
1359
+ const innerR = outerR * 0.3;
1360
+ const ringCount = vals.length || 1;
1361
+ const bandW = (outerR - innerR) / ringCount;
1362
+ const gap = Math.min(2, bandW * 0.15);
1363
+
1364
+ let svg = '';
1365
+
1366
+ /* Backing tracks + filled arcs per datum, from inner to outer.
1367
+ Uses the stroke-dasharray technique: each ring is a single <circle>,
1368
+ rotated so the dash starts at 12 o'clock, with dasharray sized to
1369
+ (filled, remainder). Avoids the "full circle" vs "arc path" branch
1370
+ that produced rendering artifacts at 100% fills. */
1371
+ for (let i = 0; i < vals.length; i++) {
1372
+ const r0 = innerR + bandW * i + gap / 2;
1373
+ const r1 = innerR + bandW * (i + 1) - gap / 2;
1374
+ const mid = (r0 + r1) / 2;
1375
+ const thickness = r1 - r0;
1376
+ const circumference = 2 * Math.PI * mid;
1377
+ const filled = Math.max(0, Math.min(1, vals[i] / maxVal)) * circumference;
1378
+
1379
+ /* Backing ring — full circle, faint stroke */
1380
+ svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;
1381
+
1382
+ if (filled <= 0) continue;
1383
+
1384
+ const pct = ((vals[i] / maxVal) * 100).toFixed(1);
1385
+ const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
1386
+
1387
+ /* Filled arc — stroke-dasharray splits the circumference into
1388
+ (drawn, gap). Rotated -90° around the center so the dash begins
1389
+ at 12 o'clock and sweeps clockwise. stroke-linecap="butt" for
1390
+ full rings (so the ends don't overlap into a wedge artifact);
1391
+ "round" for partial arcs so ends read as bar caps. */
1392
+ const isFull = Math.abs(filled - circumference) < 0.5;
1393
+ const linecap = isFull ? 'butt' : 'round';
1394
+ const dashArray = isFull
1395
+ ? `${circumference} 0`
1396
+ : `${filled} ${circumference - filled}`;
1397
+
1398
+ 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})"/>`;
1399
+ }
1400
+
1401
+ this.#legendData = data.map((d, i) => ({
1402
+ label: labels[i],
1403
+ key: labels[i],
1404
+ value: vals[i],
1405
+ pct: ((vals[i] / maxVal) * 100).toFixed(1),
1406
+ slot: i % 10,
1407
+ }));
1408
+
1409
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1410
+ }
1411
+
1412
+ /* ── Gauge (half-donut with center value) ───────────────────────
1413
+ Common KPI visualization: a 180° arc filled from the leftmost
1414
+ point (9 o'clock) clockwise to the current value. Center shows
1415
+ the value as a large number. Uses the same data-slice fill path
1416
+ as pie/donut for theme coherence. Accepts a single datum OR
1417
+ sums all data values — max is `maxVal` prop if set, otherwise
1418
+ taken as the sum of all values (treats the total as 100%). */
1419
+ #renderGauge() {
1420
+ const data = this.#data;
1421
+ const yKey = this.#yKeys()[0] || this.y;
1422
+ const vals = data.map(d => +(d[yKey] ?? 0));
1423
+ const sum = vals.reduce((a, b) => a + b, 0) || 1;
1424
+
1425
+ /* Gauge reads a single value + optional max from the first datum
1426
+ (v, max?) OR treats the first value as the numerator and the
1427
+ sum as the denominator for "X of Y" style metrics. */
1428
+ const primary = vals[0] ?? 0;
1429
+ const maxVal = data[0]?.max != null ? +data[0].max : (vals.length === 1 ? Math.max(primary, 1) : sum);
1430
+ const pct = Math.max(0, Math.min(1, primary / maxVal));
1431
+
1432
+ const dims = this.#dims();
1433
+ const { width, height } = dims;
1434
+
1435
+ /* Place the arc's visual center so the half-circle uses the full
1436
+ width below the value label. cy sits lower so the half-arc has
1437
+ room for the big center label above it. */
1438
+ const cx = width / 2;
1439
+ const cy = height * 0.68;
1440
+ const outerR = Math.max(40, Math.min(width * 0.45, height * 0.6));
1441
+ const innerR = outerR * 0.72;
1442
+
1443
+ let svg = '';
1444
+
1445
+ /* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
1446
+ clockwise through 12 o'clock (3π/2) to 3 o'clock (2π). donutArcPath
1447
+ produces a filled ring wedge; with start=π, end=2π the sliceAngle
1448
+ equals π so large-arc-flag=0 and the arc passes over the top. */
1449
+ svg += `<path data-radial-track d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, 2 * Math.PI, 0)}"/>`;
1450
+
1451
+ /* Filled portion — 0..180° proportional to pct. pct=0 draws nothing;
1452
+ pct=1 overlays the full backing arc. */
1453
+ if (pct > 0) {
1454
+ const fillEnd = Math.PI + Math.PI * pct;
1455
+ const tipAttrs = tip({ label: data[0]?.[this.x] ?? 'Value', value: primary, pct: (pct * 100).toFixed(1) });
1456
+ svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, 0)}"/>`;
1457
+ }
1458
+
1459
+ /* Center value label */
1460
+ const totalFs = Math.max(18, Math.round(outerR * 0.42));
1461
+ const labelFs = Math.max(10, Math.round(outerR * 0.2));
1462
+ const labelY = cy - outerR * 0.15;
1463
+ svg += `<text data-gauge-value x="${cx}" y="${labelY}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
1464
+ if (maxVal !== primary) {
1465
+ 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>`;
1466
+ }
1467
+
1468
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1469
+ }
1470
+
1471
+ /* ── Funnel (stage drop-off) ───────────────────────────────────
1472
+ Classic conversion funnel: stages listed top-to-bottom, each
1473
+ rendered as a trapezoid whose width shrinks proportional to its
1474
+ value. Stage labels live to the left, value + pct vs first stage
1475
+ to the right. Typical use: sales pipeline, signup funnel.
1476
+
1477
+ Trapezoid geometry: for stage i with value v_i and max value v_0:
1478
+ halfWidthTop = (v_i / v_max) * plotW / 2
1479
+ halfWidthBot = (v_{i+1} / v_max) * plotW / 2
1480
+ Last stage uses halfWidthBot = halfWidthTop (degrades to rect). */
1481
+ #renderFunnel() {
1482
+ const data = this.#data;
1483
+ const yKey = this.#yKeys()[0] || this.y;
1484
+ const vals = data.map(d => +(d[yKey] ?? 0));
1485
+ const labels = data.map(d => d[this.x] ?? '');
1486
+ const n = vals.length;
1487
+ if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
1488
+
1489
+ const maxVal = Math.max(...vals) || 1;
1490
+
1491
+ const dims = this.#dims();
1492
+ const { width, height } = dims;
1493
+ const fs = dims.fontSize;
1494
+ const padX = Math.max(width * 0.18, 80); /* room for stage labels */
1495
+ const padY = fs * 0.8;
1496
+ const plotW = width - padX * 2;
1497
+ const plotH = height - padY * 2;
1498
+ const stageH = plotH / n;
1499
+ const gap = Math.max(2, stageH * 0.08);
1500
+
1501
+ const cx = width / 2;
1502
+
1503
+ let svg = '';
1504
+ for (let i = 0; i < n; i++) {
1505
+ const vTop = vals[i];
1506
+ const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
1507
+ const halfTop = (vTop / maxVal) * (plotW / 2);
1508
+ const halfBot = (vBot / maxVal) * (plotW / 2);
1509
+ const y0 = padY + stageH * i + gap / 2;
1510
+ const y1 = padY + stageH * (i + 1) - gap / 2;
1511
+
1512
+ const pct = ((vTop / maxVal) * 100).toFixed(1);
1513
+ const tipAttrs = tip({ label: labels[i], value: vTop, pct });
1514
+
1515
+ /* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
1516
+ const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
1517
+ svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
1518
+
1519
+ /* Stage label — left-aligned outside the funnel */
1520
+ 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>`;
1521
+
1522
+ /* Value + pct — right-aligned outside the funnel */
1523
+ 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>`;
1524
+ if (i > 0) {
1525
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
1526
+ 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>`;
1527
+ }
1528
+ }
1529
+
1530
+ this.#legendData = data.map((d, i) => ({
1531
+ label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
1532
+ }));
1533
+
1534
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1535
+ }
1536
+
1537
+ /* ── Treemap (hierarchical rect tiling) ────────────────────────
1538
+ Flat squarified treemap: each datum is a rect whose area is
1539
+ proportional to its value. Not nested — Phase 5 ships flat
1540
+ rectangles; a nested variant (children arrays) is a natural
1541
+ future extension.
1542
+
1543
+ Uses the Bruls/Huijbregts/Van Wijk squarified algorithm adapted
1544
+ for single-level data: sorts values desc, packs rows that hold
1545
+ aspect ratios close to 1, alternating row direction based on
1546
+ remaining container aspect. */
1547
+ #renderTreemap() {
1548
+ const data = this.#data;
1549
+ const yKey = this.#yKeys()[0] || this.y;
1550
+ const vals = data.map(d => +(d[yKey] ?? 0));
1551
+ const labels = data.map(d => d[this.x] ?? '');
1552
+ const n = vals.length;
1553
+ if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
1554
+
1555
+ const dims = this.#dims();
1556
+ const { width, height, fontSize } = dims;
1557
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
1558
+
1559
+ /* Sort indices by value desc — squarified needs sorted input but
1560
+ we keep original indices for color/label/hit mapping. */
1561
+ const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);
1562
+
1563
+ const rects = []; /* {i, x, y, w, h} in plot coords */
1564
+
1565
+ /* Scale areas so they fill the container. */
1566
+ const area = width * height;
1567
+ const scaled = order.map(i => (vals[i] / total) * area);
1568
+
1569
+ /* Squarified packer — iterative rows. */
1570
+ let x = 0, y = 0, w = width, h = height;
1571
+ let row = [];
1572
+ let rowStart = 0;
1573
+
1574
+ const worst = (row, width) => {
1575
+ if (row.length === 0) return Infinity;
1576
+ const sum = row.reduce((a, b) => a + b, 0);
1577
+ const max = Math.max(...row);
1578
+ const min = Math.min(...row);
1579
+ const wsq = width * width;
1580
+ const ssq = sum * sum;
1581
+ return Math.max((wsq * max) / ssq, ssq / (wsq * min));
1582
+ };
1583
+
1584
+ const layoutRow = (row, rowStart, x, y, w, h) => {
1585
+ const sum = row.reduce((a, b) => a + b, 0);
1586
+ const horizontal = w >= h;
1587
+ const rowH = horizontal ? sum / w : h;
1588
+ const rowW = horizontal ? w : sum / h;
1589
+
1590
+ let offset = 0;
1591
+ for (let k = 0; k < row.length; k++) {
1592
+ const frac = row[k] / sum;
1593
+ const idx = order[rowStart + k];
1594
+ if (horizontal) {
1595
+ const segW = frac * w;
1596
+ rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
1597
+ offset += segW;
1598
+ } else {
1599
+ const segH = frac * h;
1600
+ rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
1601
+ offset += segH;
1602
+ }
1603
+ }
1604
+ if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
1605
+ return { x: x + rowW, y, w: w - rowW, h };
1606
+ };
1607
+
1608
+ let remaining = scaled.slice();
1609
+ while (remaining.length > 0) {
1610
+ row = [remaining[0]];
1611
+ rowStart = scaled.length - remaining.length;
1612
+ const shortSide = Math.min(w, h);
1613
+ let i = 1;
1614
+ while (i < remaining.length) {
1615
+ const next = row.concat(remaining[i]);
1616
+ if (worst(next, shortSide) > worst(row, shortSide)) break;
1617
+ row = next;
1618
+ i++;
1619
+ }
1620
+ const newRect = layoutRow(row, rowStart, x, y, w, h);
1621
+ x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
1622
+ remaining = remaining.slice(row.length);
1623
+ }
1624
+
1625
+ /* Emit rects + labels. */
1626
+ let svg = '';
1627
+ const pad = 2;
1628
+ for (const r of rects) {
1629
+ const pct = ((vals[r.i] / total) * 100).toFixed(1);
1630
+ const tipAttrs = tip({ label: labels[r.i], value: vals[r.i], pct });
1631
+ 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()}"/>`;
1632
+
1633
+ /* Three text-placement branches keyed on available tile size:
1634
+ - `tall` (h > ~2.5 line heights): label + value stacked, top-aligned
1635
+ - `short` (h ≥ ~1.2 line heights but < tall): label only, vertically
1636
+ centered — avoids the clipped-text look when tiles are squat
1637
+ - `tiny` (h too small for even one line): no text
1638
+ All branches gate on width > ~4× fontSize so labels don't overflow. */
1639
+ const labelX = r.x + 8;
1640
+ const canShowAny = r.w > fontSize * 4;
1641
+ const tall = canShowAny && r.h > fontSize * 2.5;
1642
+ const short = canShowAny && !tall && r.h > fontSize * 1.2;
1643
+
1644
+ if (tall) {
1645
+ svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(labels[r.i])}</text>`;
1646
+ 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>`;
1647
+ } else if (short) {
1648
+ const cy = r.y + r.h / 2;
1649
+ svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(labels[r.i])}</text>`;
1650
+ }
1651
+ }
1652
+
1653
+ this.#legendData = data.map((d, i) => ({
1654
+ label: labels[i], value: vals[i], pct: ((vals[i] / total) * 100).toFixed(1), slot: i % 10,
1655
+ }));
1656
+
1657
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1658
+ }
1659
+
1660
+ /* ── Sankey (flow between source and target nodes) ─────────────
1661
+ Flow diagram with columns of nodes and curved bands connecting
1662
+ them. Data shape for Sankey differs from other chart types:
1663
+ data = [{ source: 'A', target: 'B', value: 10 }, ...]
1664
+ Nodes are auto-derived from unique source/target values.
1665
+ Lays out two columns (source-left, target-right) with node
1666
+ heights proportional to throughput. */
1667
+ #renderSankey() {
1668
+ const data = this.#data;
1669
+ if (!data.length) return { svg: '', viewBox: '0 0 100 100' };
1670
+
1671
+ /* Derive source + target node sets from the link data. */
1672
+ const sourceSet = new Map(); /* name → { outflow, y0, y1 } */
1673
+ const targetSet = new Map(); /* name → { inflow, y0, y1 } */
1674
+ for (const link of data) {
1675
+ const s = link.source ?? link.from ?? '';
1676
+ const t = link.target ?? link.to ?? '';
1677
+ const v = +(link.value ?? link.v ?? 0);
1678
+ if (!sourceSet.has(s)) sourceSet.set(s, { name: s, flow: 0 });
1679
+ if (!targetSet.has(t)) targetSet.set(t, { name: t, flow: 0 });
1680
+ sourceSet.get(s).flow += v;
1681
+ targetSet.get(t).flow += v;
1682
+ }
1683
+
1684
+ const dims = this.#dims();
1685
+ const { width, height, fontSize } = dims;
1686
+ const nodeW = Math.max(8, width * 0.03);
1687
+ const colPad = nodeW + fontSize * 5;
1688
+
1689
+ const sourceTotal = [...sourceSet.values()].reduce((a, s) => a + s.flow, 0) || 1;
1690
+ const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
1691
+
1692
+ const nodeGap = fontSize * 0.6;
1693
+
1694
+ /* Assign y0/y1 to each source node, stacked top-to-bottom. */
1695
+ const sources = [...sourceSet.values()];
1696
+ const targets = [...targetSet.values()];
1697
+ const sourceTotalH = height - nodeGap * (sources.length - 1);
1698
+ const targetTotalH = height - nodeGap * (targets.length - 1);
1699
+
1700
+ let y = 0;
1701
+ for (const s of sources) {
1702
+ const h = (s.flow / sourceTotal) * sourceTotalH;
1703
+ s.y0 = y;
1704
+ s.y1 = y + h;
1705
+ s.cursor = y; /* running y for outgoing links */
1706
+ y += h + nodeGap;
1707
+ }
1708
+ y = 0;
1709
+ for (const t of targets) {
1710
+ const h = (t.flow / targetTotal) * targetTotalH;
1711
+ t.y0 = y;
1712
+ t.y1 = y + h;
1713
+ t.cursor = y;
1714
+ y += h + nodeGap;
1715
+ }
1716
+
1717
+ let svg = '';
1718
+
1719
+ const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);
1720
+
1721
+ /* Source nodes — left column */
1722
+ sources.forEach((s, i) => {
1723
+ svg += `<rect data-sankey-node data-slice="${i % 10}" x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
1724
+ 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>`;
1725
+ });
1726
+
1727
+ /* Target nodes — right column */
1728
+ targets.forEach((t, i) => {
1729
+ 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}"/>`;
1730
+ 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>`;
1731
+ });
1732
+
1733
+ /* Links — bezier bands */
1734
+ for (const link of data) {
1735
+ const s = sourceSet.get(link.source ?? link.from ?? '');
1736
+ const t = targetSet.get(link.target ?? link.to ?? '');
1737
+ if (!s || !t) continue;
1738
+ const v = +(link.value ?? link.v ?? 0);
1739
+ if (v <= 0) continue;
1740
+
1741
+ const sH = (v / sourceTotal) * sourceTotalH;
1742
+ const tH = (v / targetTotal) * targetTotalH;
1743
+ const sTop = s.cursor;
1744
+ const sBot = sTop + sH;
1745
+ const tTop = t.cursor;
1746
+ const tBot = tTop + tH;
1747
+ s.cursor += sH;
1748
+ t.cursor += tH;
1749
+
1750
+ const x0 = colPad;
1751
+ const x1 = width - colPad;
1752
+ const mx = (x0 + x1) / 2;
1753
+ const tipAttrs = tip({ label: `${s.name} → ${t.name}`, value: v });
1754
+
1755
+ const path = `M ${x0} ${sTop} C ${mx} ${sTop}, ${mx} ${tTop}, ${x1} ${tTop} L ${x1} ${tBot} C ${mx} ${tBot}, ${mx} ${sBot}, ${x0} ${sBot} Z`;
1756
+ svg += `<path data-sankey-link${tipAttrs} d="${path}"/>`;
1757
+ }
1758
+
1759
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1760
+ }
1761
+
1762
+ /* ── Composed (bar + line overlay) ─────────────────────────────
1763
+ Multi-series combining a bar chart for primary values with a line
1764
+ overlay for secondary values. Data shape: each row has the x-key,
1765
+ a `bar` key, and a `line` key. Both are axis-aligned against the
1766
+ same Y scale (single axis for v1; dual-axis future extension).
1767
+
1768
+ Uses y="bar,line" as the series keys by convention. */
1769
+ #renderComposed() {
1770
+ const dims = this.#dims();
1771
+ const data = this.#data;
1772
+ const keys = this.#yKeys();
1773
+ const barKey = keys[0] || 'bar';
1774
+ const lineKey = keys[1] || 'line';
1775
+ const labels = data.map(d => d[this.x] ?? '');
1776
+ const barVals = data.map(d => +(d[barKey] ?? 0));
1777
+ const lineVals = data.map(d => +(d[lineKey] ?? 0));
1778
+ const allVals = [...barVals, ...lineVals];
1779
+ const ticks = niceScale(0, Math.max(...allVals), 5);
1780
+ const maxVal = ticks[ticks.length - 1];
1781
+
1782
+ const { width, height, pad } = dims;
1783
+ const plotH = height - pad.top - pad.bottom;
1784
+ const plotW = width - pad.left - pad.right;
1785
+ const barW = plotW / data.length;
1786
+ const barInner = barW * 0.6;
1787
+ const barGap = (barW - barInner) / 2;
1788
+ const step = plotW / Math.max(data.length - 1, 1);
1789
+
1790
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1791
+
1792
+ /* Bar series (slot 0) */
1793
+ if (!this.#isSeriesHidden(barKey)) {
1794
+ for (let i = 0; i < data.length; i++) {
1795
+ const v = barVals[i];
1796
+ const barH = maxVal ? (v / maxVal) * plotH : 0;
1797
+ const bx = pad.left + barW * i + barGap;
1798
+ const by = pad.top + plotH - barH;
1799
+ 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()}"/>`;
1800
+ }
1801
+ }
1802
+
1803
+ /* Line series (slot 1) — anchored at bar center X positions */
1804
+ if (!this.#isSeriesHidden(lineKey)) {
1805
+ const points = lineVals.map((v, i) => ({
1806
+ x: pad.left + barW * i + barW / 2,
1807
+ y: pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0),
1808
+ v, label: labels[i],
1809
+ }));
1810
+ const t = Math.max(0, Math.min(1, this.smooth));
1811
+ svg += `<path data-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(points, t)}"/>`;
1812
+ for (const p of points) {
1813
+ svg += `<circle data-dot${this.#seriesFill(1, lineKey)} cx="${p.x}" cy="${p.y}" r="3"/>`;
1814
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
1815
+ }
1816
+ }
1817
+
1818
+ this.#legendData = [
1819
+ { label: barKey, key: barKey, slot: 0 },
1820
+ { label: lineKey, key: lineKey, slot: 1 },
1821
+ ];
1822
+
1823
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1824
+ }
1825
+
818
1826
  /* ── Segments (single horizontal stacked bar) ──────────────────
819
1827
  Categorical data rendered as one horizontal bar split into
820
1828
  proportional colored slices. Good for compact in-card
@@ -915,6 +1923,7 @@ class AdiaChart extends AdiaElement {
915
1923
  let stackY = pad.top + plotH;
916
1924
  const segCount = keys.length;
917
1925
  for (let k = 0; k < segCount; k++) {
1926
+ if (this.#isSeriesHidden(keys[k])) continue;
918
1927
  const v = +(data[i][keys[k]] ?? 0);
919
1928
  const segH = maxVal ? (v / maxVal) * plotH : 0;
920
1929
  if (segH <= 0) { stackY -= segH; continue; }
@@ -926,7 +1935,7 @@ class AdiaChart extends AdiaElement {
926
1935
  const isBottom = k === 0;
927
1936
  const r = Math.min(this.#resolveRadius(), barInner / 2, bh / 2);
928
1937
 
929
- const attrs = ` data-slice="${k % 10}"${tip({ label: labels[i], value: v, series: keys[k] })}`;
1938
+ const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
930
1939
 
931
1940
  if (isTop && isBottom) {
932
1941
  // Single segment — round top + bottom
@@ -944,7 +1953,7 @@ class AdiaChart extends AdiaElement {
944
1953
  }
945
1954
  }
946
1955
 
947
- this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
1956
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
948
1957
 
949
1958
  return { svg, viewBox: `0 0 ${width} ${height}` };
950
1959
  }
@@ -974,19 +1983,20 @@ class AdiaChart extends AdiaElement {
974
1983
 
975
1984
  for (let i = 0; i < data.length; i++) {
976
1985
  for (let k = 0; k < keys.length; k++) {
1986
+ if (this.#isSeriesHidden(keys[k])) continue;
977
1987
  const v = +(data[i][keys[k]] ?? 0);
978
1988
  const barH = maxVal ? (v / maxVal) * plotH : 0;
979
1989
  const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
980
1990
  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()}"/>`;
1991
+ 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
1992
 
983
1993
  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>`;
1994
+ svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
985
1995
  }
986
1996
  }
987
1997
  }
988
1998
 
989
- this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
1999
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
990
2000
 
991
2001
  return { svg, viewBox: `0 0 ${width} ${height}` };
992
2002
  }
@@ -1011,6 +2021,7 @@ class AdiaChart extends AdiaElement {
1011
2021
  let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1012
2022
 
1013
2023
  for (let k = 0; k < keys.length; k++) {
2024
+ if (this.#isSeriesHidden(keys[k])) continue;
1014
2025
  const vals = data.map(d => +(d[keys[k]] ?? 0));
1015
2026
  const points = vals.map((v, i) => {
1016
2027
  const px = pad.left + step * i;
@@ -1022,20 +2033,20 @@ class AdiaChart extends AdiaElement {
1022
2033
  const t = Math.max(0, Math.min(1, this.smooth));
1023
2034
 
1024
2035
  /* Area fill */
1025
- svg += `<path data-area data-slice="${k % 10}" d="${smoothAreaPath(points, baseline, t)}"/>`;
2036
+ svg += `<path data-area${this.#seriesFill(k % 10, keys[k])} d="${smoothAreaPath(points, baseline, t)}"/>`;
1026
2037
 
1027
2038
  /* Line */
1028
- svg += `<path data-line data-slice="${k % 10}" d="${smoothPath(points, t)}"/>`;
2039
+ svg += `<path data-line${this.#seriesStroke(k % 10, keys[k])} d="${smoothPath(points, t)}"/>`;
1029
2040
 
1030
2041
  /* Dots + hit targets. Hit circles deliberately omit data-slice so
1031
2042
  they aren't caught by the circle[data-slice] fill rule in CSS. */
1032
2043
  for (const p of points) {
1033
- svg += `<circle data-dot data-slice="${k % 10}" cx="${p.x}" cy="${p.y}" r="3"/>`;
2044
+ svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])} cx="${p.x}" cy="${p.y}" r="3"/>`;
1034
2045
  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
2046
  }
1036
2047
  }
1037
2048
 
1038
- this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
2049
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
1039
2050
 
1040
2051
  return { svg, viewBox: `0 0 ${width} ${height}` };
1041
2052
  }
@@ -1044,6 +2055,14 @@ class AdiaChart extends AdiaElement {
1044
2055
 
1045
2056
  #legendData = null;
1046
2057
 
2058
+ /* Public getter — external consumers (chart-legend-ui[for]) read this to
2059
+ mirror series data. Returns a defensive shallow copy so callers can't
2060
+ mutate our internals. Null when the chart has no legend-bearing type
2061
+ or hasn't rendered yet. */
2062
+ get legendData() {
2063
+ return this.#legendData ? this.#legendData.map(it => ({ ...it })) : null;
2064
+ }
2065
+
1047
2066
  #buildLegend() {
1048
2067
  if (!this.#legendData || !this.#legendData.length) return null;
1049
2068
 
@@ -1053,10 +2072,12 @@ class AdiaChart extends AdiaElement {
1053
2072
  for (const item of this.#legendData) {
1054
2073
  const el = document.createElement('span');
1055
2074
  el.setAttribute('data-legend-item', '');
2075
+ if (item.key) el.setAttribute('data-series-key', item.key);
1056
2076
 
1057
2077
  const dot = document.createElement('span');
1058
2078
  dot.setAttribute('data-legend-dot', '');
1059
2079
  dot.setAttribute('data-slice', String(item.slot));
2080
+ if (item.key) dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
1060
2081
  el.appendChild(dot);
1061
2082
 
1062
2083
  const text = document.createElement('span');
@@ -1066,7 +2087,8 @@ class AdiaChart extends AdiaElement {
1066
2087
  legend.appendChild(el);
1067
2088
  }
1068
2089
 
1069
- this.#legendData = null;
2090
+ /* Intentionally DO NOT null #legendData here — public `legendData`
2091
+ getter needs it to survive so chart-legend-ui[for] can mirror. */
1070
2092
  return legend;
1071
2093
  }
1072
2094
  }