/**
 * Non-side-effect class export for `<chart-ui>`.
 *
 * Importing this file gives you the class(es) without auto-registering the tag.
 * Useful for test isolation, subclassing with tag-name override, or selective
 * composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/chart`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <chart-ui type="bar" x="month" y="revenue"></chart-ui>
 *
 * Declarative chart component supporting bar, line, pie, donut, radar,
 * sparkline, stacked-bar, grouped-bar, and multi-line chart types.
 *
 * Attributes:
 *   type         — chart type (default: 'bar')
 *   x            — key for X-axis data
 *   y            — key(s) for Y-axis, comma-separated for multi-series
 *   hide-average — suppress the overlaid average line on bar/line (default: false — line shown)
 *   color        — accent/success/warning/danger/info
 *   hide-grid    — hide gridlines
 *   hide-values  — hide value labels
 *   radius       — bar corner radius (default: 4)
 *
 * JS API:
 *   .data = [{...}, ...]  — array of data objects
 */

import { UIElement } from '../../core/element.js';

/* ── Helpers ────────────────────────────────────────────────────── */

function niceScale(min, max, ticks = 5) {
  const range = max - min || 1;
  const rough = range / ticks;
  const mag = Math.pow(10, Math.floor(Math.log10(rough)));
  const norm = rough / mag;
  const nice = norm <= 1.5 ? 1 : norm <= 3 ? 2 : norm <= 7 ? 5 : 10;
  const step = nice * mag;
  const lo = Math.floor(min / step) * step;
  const hi = Math.ceil(max / step) * step;
  const result = [];
  for (let v = lo; v <= hi + step * 0.5; v += step) result.push(+v.toFixed(10));
  return result;
}

function fmt(v) {
  if (v == null) return '';
  const n = +v;
  if (Number.isNaN(n)) return String(v);
  if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
  if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
  return Number.isInteger(n) ? String(n) : n.toFixed(1);
}

function esc(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/* Emit tooltip data-attributes for a datum shape. Consumed by the pointer
   delegate below. All fields optional — unused ones are skipped. */
function tip({ label, value, pct, series }) {
  let s = '';
  if (label  != null) s += ` data-tip-label="${esc(String(label))}"`;
  if (value  != null) s += ` data-tip-value="${value}"`;
  if (pct    != null) s += ` data-tip-pct="${pct}"`;
  if (series != null) s += ` data-tip-series="${esc(String(series))}"`;
  return s;
}

/* Polar helpers for pie/donut */
function polarX(cx, r, angle) { return cx + r * Math.cos(angle); }
function polarY(cy, r, angle) { return cy + r * Math.sin(angle); }

function arcPath(cx, cy, r, start, end) {
  const x1 = polarX(cx, r, start);
  const y1 = polarY(cy, r, start);
  const x2 = polarX(cx, r, end);
  const y2 = polarY(cy, r, end);
  const large = end - start > Math.PI ? 1 : 0;
  return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`;
}

function donutArcPath(cx, cy, outer, inner, start, end, radius = 0) {
  // Radius is clamped to half the ring thickness. A radius of 0 renders
  // a flat (original) wedge with sharp corners.
  const ringHalf = (outer - inner) / 2;
  const sliceAngle = end - start;
  const r = Math.max(0, Math.min(radius, ringHalf));
  const flatPath = () => {
    const x1 = polarX(cx, outer, start);
    const y1 = polarY(cy, outer, start);
    const x2 = polarX(cx, outer, end);
    const y2 = polarY(cy, outer, end);
    const x3 = polarX(cx, inner, end);
    const y3 = polarY(cy, inner, end);
    const x4 = polarX(cx, inner, start);
    const y4 = polarY(cy, inner, start);
    const large = sliceAngle > Math.PI ? 1 : 0;
    return `M ${x1} ${y1} A ${outer} ${outer} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${inner} ${inner} 0 ${large} 0 ${x4} ${y4} Z`;
  };
  if (r <= 0) return flatPath();

  // Rounded-CORNER wedge (rounded rectangle-on-ring).
  // Each end has two small corner arcs connected by a short radial line,
  // so thin slices on thick rings don't collapse into pill shapes.
  const aOuter = r / outer;
  const aInner = r / inner;
  if (sliceAngle <= (aOuter + aInner) * 1.05) return flatPath();

  const sOuter = start + aOuter;
  const eOuter = end   - aOuter;
  const sInner = start + aInner;
  const eInner = end   - aInner;
  const large  = (eOuter - sOuter) > Math.PI ? 1 : 0;

  // Points: outer-arc ends, radial-line ends at each angular extremum.
  const ox1 = polarX(cx, outer, sOuter);
  const oy1 = polarY(cy, outer, sOuter);
  const ox2 = polarX(cx, outer, eOuter);
  const oy2 = polarY(cy, outer, eOuter);
  const ix2 = polarX(cx, inner, eInner);
  const iy2 = polarY(cy, inner, eInner);
  const ix1 = polarX(cx, inner, sInner);
  const iy1 = polarY(cy, inner, sInner);

  // Radial-line endpoints: the corner pushes the radial inward by `r`,
  // so the straight segment runs from (outer-r) down to (inner+r) at the
  // end angle, and symmetrically at the start angle.
  const rsOuter = polarX(cx, outer - r, start), rsOuterY = polarY(cy, outer - r, start);
  const rsInner = polarX(cx, inner + r, start), rsInnerY = polarY(cy, inner + r, start);
  const reOuter = polarX(cx, outer - r, end),   reOuterY = polarY(cy, outer - r, end);
  const reInner = polarX(cx, inner + r, end),   reInnerY = polarY(cy, inner + r, end);

  return `M ${ox1} ${oy1} ` +
    `A ${outer} ${outer} 0 ${large} 1 ${ox2} ${oy2} ` +      // outer arc (CW)
    `A ${r} ${r} 0 0 1 ${reOuter} ${reOuterY} ` +            // end outer corner
    `L ${reInner} ${reInnerY} ` +                            // end radial line
    `A ${r} ${r} 0 0 1 ${ix2} ${iy2} ` +                     // end inner corner
    `A ${inner} ${inner} 0 ${large} 0 ${ix1} ${iy1} ` +      // inner arc (CCW)
    `A ${r} ${r} 0 0 1 ${rsInner} ${rsInnerY} ` +            // start inner corner
    `L ${rsOuter} ${rsOuterY} ` +                            // start radial line
    `A ${r} ${r} 0 0 1 ${ox1} ${oy1} Z`;                     // start outer corner
}

/**
 * Smooth path — converts points [{x,y}] to a cubic bezier SVG path.
 * t = 0 → straight lines (polyline), t = 1 → maximum smoothing.
 * Uses Catmull-Rom-to-Bezier spline conversion.
 */
function smoothPath(points, t = 0.4) {
  if (points.length < 2) return '';
  if (t <= 0) return 'M' + points.map(p => `${p.x},${p.y}`).join(' L');

  const n = points.length;
  let d = `M${points[0].x},${points[0].y}`;

  for (let i = 0; i < n - 1; i++) {
    const p0 = points[Math.max(i - 1, 0)];
    const p1 = points[i];
    const p2 = points[i + 1];
    const p3 = points[Math.min(i + 2, n - 1)];

    const cp1x = p1.x + (p2.x - p0.x) * t / 3;
    const cp1y = p1.y + (p2.y - p0.y) * t / 3;
    const cp2x = p2.x - (p3.x - p1.x) * t / 3;
    const cp2y = p2.y - (p3.y - p1.y) * t / 3;

    d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
  }

  return d;
}

/** Build a closed area path from a smooth line path + baseline Y */
function smoothAreaPath(points, baselineY, t = 0.4) {
  const line = smoothPath(points, t);
  const last = points[points.length - 1];
  const first = points[0];
  return `${line} L${last.x},${baselineY} L${first.x},${baselineY} Z`;
}

/** Build a column-bar path with only the TOP corners rounded so the bar
 *  sits flush on its baseline axis. Used for bar / grouped-bar / composed
 *  bar series / stacked-bar single + top segments — the value end gets a
 *  cap, the axis end stays square. r is clamped to (w/2, h) to handle
 *  short bars without the arcs overlapping or escaping the rect. */
function topRoundedBarPath(x, y, w, h, r = 0) {
  if (h <= 0 || w <= 0) return '';
  const rr = Math.max(0, Math.min(r, w / 2, h));
  if (rr === 0) {
    return `M${x},${y} H${x + w} V${y + h} H${x} Z`;
  }
  return `M${x},${y + h} V${y + rr} Q${x},${y} ${x + rr},${y} H${x + w - rr} Q${x + w},${y} ${x + w},${y + rr} V${y + h} Z`;
}

/* ── Aspect ratios ─────────────────────────────────────────────── */

// Implicit aspect ratio — a chart derives its height from width ÷ this ratio when
// no explicit height is set. The per-chart [aspect] attribute (std/wide/square/tall)
// was retired (OD-CHART-02); parents size charts directly now.
const ASPECTS = { std: { ratio: 4 / 3 } };

/* ── Component ──────────────────────────────────────────────────── */

export class UIChart extends UIElement {
  static properties = {
    type:        { type: String,  default: 'bar',    reflect: true },
    x:           { type: String,  default: '',       reflect: true },
    y:           { type: String,  default: '',       reflect: true },
    hideAverage: { type: Boolean, default: false,    reflect: true, attribute: 'hide-average' },
    color:       { type: String,  default: '',       reflect: true },
    hideGrid:    { type: Boolean, default: false,    reflect: true, attribute: 'hide-grid' },
    hideValues:  { type: Boolean, default: false,    reflect: true, attribute: 'hide-values' },
    radius:      { type: Number,  default: null,     reflect: true },
    smooth:      { type: Number,  default: 0.4,      reflect: true },
    size:        { type: String,  default: '',       reflect: true },
    format:      { type: String,  default: 'abbr',   reflect: true },
    loading:     { type: Boolean, default: false,    reflect: true },
  };

  static template = () => null;

  #data = [];
  #shapeWarned = false;  // FEEDBACK-24: one-shot wrong-.data-shape warning
  #resizeObs = null;
  #resizeRaf = null;
  #lastW = 0;
  #lastH = 0;
  /* Set of series keys hidden by external chart-legend-ui[for=self] toggles.
     Kept at render time; repopulated lookups rebuild with the new set. */
  #hiddenSeriesKeys = new Set();

  /** Resolves the corner radius: the `radius` prop if explicitly set,
   *  otherwise `--a-radius` from the host's computed style. Falls back
   *  to 6 if the token is unset (should not happen in practice). */
  #resolveRadius() {
    if (this.radius != null) return this.radius;
    const val = getComputedStyle(this).getPropertyValue('--a-radius').trim();
    const parsed = parseFloat(val);
    return Number.isFinite(parsed) && parsed > 0 ? parsed : 6;
  }

  set data(arr) {
    // FEEDBACK-24: a non-array — typically the Chart.js `{labels, datasets}`
    // envelope — is silently coerced to [] below, producing a blank chart
    // with no diagnostic. Warn once per element.
    if (!Array.isArray(arr) && !this.#shapeWarned) {
      this.#shapeWarned = true;
      // eslint-disable-next-line no-console
      console.warn(
        `[chart-ui] .data must be an array of plain objects — received ` +
        `${arr === null ? 'null' : typeof arr}. The {labels, datasets} ` +
        `envelope is the Chart.js API, not the chart-ui API.\n` +
        `Correct shape for <chart-ui x="label" y="value">:\n` +
        `  [{ label: "Jan", value: 100 }, { label: "Feb", value: 200 }]`,
      );
    }
    this.#data = Array.isArray(arr) ? arr : [];
    this.#warnIfOverBudget();
    this.#renderChart();
  }

  get data() {
    return this.#data;
  }

  /* OD-CHART-11 — one-shot perf-budget warning. Chart-ui re-renders the
     full SVG string on every data change; perf drops noticeably past
     ~5,000 datums (the tipping point varies by type — scatter and
     multi-line are the worst offenders). Authors over the budget should
     downsample at the data layer (LTTB, uniform sampling, aggregation
     by bucket) before setting .data. Override the threshold via the
     `--chart-perf-budget` CSS token for ad-hoc testing. */
  #perfBudgetWarned = false;
  #warnIfOverBudget() {
    if (this.#perfBudgetWarned) return;
    const budget = this.#readPerfBudget();
    if (this.#data.length > budget) {
      console.warn(
        `[chart-ui] .data has ${this.#data.length} rows which exceeds the ` +
        `recommended perf budget of ${budget}. Consider downsampling at the ` +
        `data layer (e.g., LTTB, bucket aggregation). Override the budget ` +
        `via CSS: chart-ui { --chart-perf-budget: 10000 }.`
      );
      this.#perfBudgetWarned = true;
    }
  }
  #readPerfBudget() {
    const raw = getComputedStyle(this).getPropertyValue('--chart-perf-budget').trim();
    const n = parseInt(raw, 10);
    return Number.isFinite(n) && n > 0 ? n : 5000;
  }

  connected() {
    if (!this.hasAttribute('role'))       this.setAttribute('role', 'img');
    if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', `${this.type} chart`);

    /* Hydrate from inline `data="[…]"` HTML attribute. The canonical
       entry point is the `.data` property (set programmatically), but
       consumers commonly try the same declarative attribute shape that
       every other chart prop accepts — `<chart-ui data='[…]' x="m"
       y="v">`. UIElement's property system doesn't deserialize JSON
       array attributes, so a static-HTML chart authored that way would
       otherwise render empty. JSON-parse once at connect; malformed
       payloads are ignored silently and a property assignment later
       still wins. */
    if (this.#data.length === 0 && this.hasAttribute('data')) {
      try {
        const parsed = JSON.parse(this.getAttribute('data'));
        if (Array.isArray(parsed)) this.data = parsed;
      } catch (_) { /* malformed JSON — leave empty, render() bails on no data */ }
    }

    /* Listen for canonical `toggle` events bubbled from external
       chart-legend-ui[for=self] descendants. The handler filters by
       target so other components dispatching `toggle` (accordion-ui,
       agent-trace-ui, etc.) don't interfere. */
    document.addEventListener('toggle', this.#onLegendToggle);

    /* OD-CHART-06 — keyboard a11y. Chart becomes focusable; arrow keys
       move a virtual focus across datums in DOM order, Enter/Space fires
       chart-select, Escape clears focus. Per-datum focus dispatches the
       same chart-hover event the pointer path uses, so tooltip-ui[for]
       tracks keyboard focus transparently. */
    if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
    this.addEventListener('keydown', this.#onKeydown);
    this.addEventListener('focus',   this.#onFocus);
    this.addEventListener('blur',    this.#onBlur);
    /* Pointer/click handlers attached to the host (not per-render SVG) so
       render() stays listener-graph-idempotent. Handlers use
       e.target.closest('[data-tip-*]') so they only fire on real datums. */
    this.addEventListener('pointerover',  this.#onPointerOver);
    this.addEventListener('pointermove',  this.#onPointerMove);
    this.addEventListener('pointerleave', this.#onPointerLeave);
    this.addEventListener('pointerdown',  this.#onPointerDown);
    this.addEventListener('click',        this.#onClick);

    this.#resizeObs = new ResizeObserver((entries) => {
      const { inlineSize: w, blockSize: h } = entries[0].contentBoxSize[0];
      if (!this.#data.length) return;
      const rw = Math.round(w);
      const rh = Math.round(h);
      if (rw === this.#lastW && (!this.hasAttribute('resize') || rh === this.#lastH)) return;
      this.#lastW = rw;
      this.#lastH = rh;
      if (this.#resizeRaf) return;
      this.#resizeRaf = requestAnimationFrame(() => {
        this.#resizeRaf = null;
        this.#renderChart();
      });
    });
    this.#resizeObs.observe(this);
  }

  render() {
    // §FB-12: loading state — replace chart body with a skeleton placeholder.
    // Preserves the element's dimensions (aspect ratio CSS stays in effect)
    // so the skeleton occupies the same space the chart will fill.
    if (this.loading) {
      let sk = this.querySelector('skeleton-ui[data-chart-skel]');
      if (!sk) {
        sk = document.createElement('skeleton-ui');
        sk.setAttribute('data-chart-skel', '');
        sk.style.cssText = 'display:block;width:100%;height:100%;min-height:4rem;border-radius:var(--a-radius-md)';
      }
      this.innerHTML = '';
      this.appendChild(sk);
      return;
    }
    // Remove skeleton if loading was cleared
    const sk = this.querySelector('skeleton-ui[data-chart-skel]');
    if (sk) sk.remove();
    this.#renderChart();
  }

  /**
   * Compute layout dimensions from actual container width.
   * Returns { width, height, pad, fontSize, labelSize, valueSize, barMinW }
   */
  #dims() {
    const containerW = this.clientWidth || 300;
    const containerH = this.clientHeight || 0;
    const aspect = ASPECTS.std;
    const n = this.#data.length || 1;

    const width = Math.max(containerW, 120);
    // When element has explicit height (resize handle, CSS height, inline style),
    // use it. Otherwise derive from aspect ratio. Safe because resize sets
    // overflow:auto which prevents content from pushing height.
    const hasExplicitHeight = this.hasAttribute('resize') || this.style.height;
    let height = hasExplicitHeight && containerH > 40
      ? containerH
      : Math.round(width / aspect.ratio);

    // Cap against --chart-max-height (CSS-resolved to px by getComputedStyle).
    // Keeps wide-viewport renders from producing absurdly tall charts while
    // staying overridable — set --chart-max-height: none (or a larger value)
    // per-chart to opt out.
    const maxH = parseFloat(getComputedStyle(this).maxHeight);
    if (isFinite(maxH) && maxH > 0 && height > maxH) height = Math.round(maxH);

    // Scale-aware font sizes: consistent regardless of chart dimensions
    // Use sm/md/lg or auto-detect from smallest dimension
    const minDim = Math.min(width, height);
    const sizeClass = this.size || (minDim < 150 ? 'sm' : minDim < 300 ? 'md' : 'lg');
    const fontSize = sizeClass === 'sm' ? 9 : sizeClass === 'md' ? 10 : 11;
    const labelSize = fontSize;
    const valueSize = sizeClass === 'sm' ? 8 : sizeClass === 'md' ? 9 : 10;

    // Padding scales with font size and axis visibility.
    // When the grid is hidden (in-card sparkline-like use) we can zero
    // out the Y-axis label gutter entirely — otherwise the plot area
    // collapses at small sizes.
    const hideGrid = this.hideGrid;
    const yLabelW = hideGrid ? 0 : fontSize * 3.2;
    const hasXLabels = !!this.x;
    const pad = {
      top:    hideGrid ? 2 : fontSize * 1.6,
      right:  hideGrid ? 2 : fontSize * 1.2,
      bottom: !hasXLabels || hideGrid ? 2 : fontSize * 2.2,
      left:   yLabelW,
    };

    // Bar width adapts to data count + container
    const plotW = width - pad.left - pad.right;
    const barMinW = Math.max(4, plotW / n * 0.6);

    return { width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass };
  }

  /* ── Main render ──────────────────────────────────────────────── */

  #emptySlotTemplate = null;

  #renderChart() {
    if (!this.isConnected) return;

    /* Preserve the empty slot across re-renders. First render captures the
       author-provided <* slot="empty"> into a template; subsequent renders
       restore it so data-empty → data-present → data-empty transitions
       don't lose the slotted empty-state-ui. Visibility is CSS-toggled via
       [data-has-data] on the host. */
    const existingEmpty = this.querySelector(':scope > [slot="empty"]');
    if (existingEmpty && !this.#emptySlotTemplate) {
      this.#emptySlotTemplate = existingEmpty.cloneNode(true);
    }

    this.innerHTML = '';
    if (this.#emptySlotTemplate) this.appendChild(this.#emptySlotTemplate.cloneNode(true));

    if (!this.#data.length) {
      this.removeAttribute('data-has-data');
      return;
    }
    this.setAttribute('data-has-data', '');

    this.#injectSeriesColors();

    const svgWrap = document.createElement('div');
    if (this.type === 'sparkline') svgWrap.setAttribute('data-sparkline', '');
    this.appendChild(svgWrap);

    const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svgWrap.appendChild(svgEl);

    let svgContent = '';
    let vb = '';

    switch (this.type) {
      case 'bar':         ({ svg: svgContent, viewBox: vb } = this.#renderBar()); break;
      case 'line':        ({ svg: svgContent, viewBox: vb } = this.#renderLine()); break;
      case 'pie':         ({ svg: svgContent, viewBox: vb } = this.#renderPie()); break;
      case 'donut':       ({ svg: svgContent, viewBox: vb } = this.#renderDonut()); break;
      case 'radar':       ({ svg: svgContent, viewBox: vb } = this.#renderRadar()); break;
      case 'sparkline':   ({ svg: svgContent, viewBox: vb } = this.#renderSparkline()); break;
      case 'segments':    ({ svg: svgContent, viewBox: vb } = this.#renderSegments()); break;
      case 'area':        ({ svg: svgContent, viewBox: vb } = this.#renderArea()); break;
      case 'scatter':     ({ svg: svgContent, viewBox: vb } = this.#renderScatter()); break;
      case 'radial-bar':  ({ svg: svgContent, viewBox: vb } = this.#renderRadialBar()); break;
      case 'gauge':       ({ svg: svgContent, viewBox: vb } = this.#renderGauge()); break;
      case 'funnel':      ({ svg: svgContent, viewBox: vb } = this.#renderFunnel()); break;
      case 'treemap':     ({ svg: svgContent, viewBox: vb } = this.#renderTreemap()); break;
      case 'sankey':      ({ svg: svgContent, viewBox: vb } = this.#renderSankey()); break;
      case 'composed':    ({ svg: svgContent, viewBox: vb } = this.#renderComposed()); break;
      case 'stacked-bar': ({ svg: svgContent, viewBox: vb } = this.#renderStackedBar()); break;
      case 'grouped-bar': ({ svg: svgContent, viewBox: vb } = this.#renderGroupedBar()); break;
      case 'multi-line':  ({ svg: svgContent, viewBox: vb } = this.#renderMultiLine()); break;
      default:            ({ svg: svgContent, viewBox: vb } = this.#renderBar()); break;
    }

    svgEl.setAttribute('viewBox', vb);
    svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
    svgEl.innerHTML = svgContent;

    /* Append legend for types that need it. Legend data is also exposed via
       the public `legendData` getter so a sibling chart-legend-ui[for] can
       mirror the same series. When an external legend is present (Phase 1b:
       chart-legend-ui[for=self]), the internal one is suppressed so we
       don't double-render — the #legendData remains populated for the
       external to read. */
    if (['pie', 'donut', 'stacked-bar', 'grouped-bar', 'multi-line', 'radial-bar'].includes(this.type)
        && !this.#hasExternalLegend()) {
      const legend = this.#buildLegend();
      if (legend) this.appendChild(legend);
    }

    /* Notify external legend/tooltip consumers that legendData has refreshed. */
    this.dispatchEvent(new CustomEvent('legend-update', { bubbles: true }));

    /* Hover tooltip + custom events are wired in connected() — host-level
       so they survive the innerHTML wipe at render. Internal tooltip
       (#tipEl) remains for back-compat; Phase 2 retires it when
       tooltip-ui[follows=pointer] lands. */
  }

  /* ── Per-series --color-{key} injection ──
     For each declared series key (`y="revenue,users"`), emit an inline CSS
     custom property on the host mapping the key to the categorical palette
     slot it occupies. Consumers override `--color-revenue` at any ancestor
     to recolor that series across chart + legend + (Phase 2) tooltip. */
  #injectSeriesColors() {
    const keys = this.#yKeys();
    for (let i = 0; i < keys.length; i++) {
      const slot = i % 10;
      this.style.setProperty(`--color-${keys[i]}`, `var(--chart-${slot})`);
    }
  }

  /* Render-time helpers — emit data-slice + series-key + inline style that
     references `--color-{key}` with fallback to `--chart-{slot}`. Inline
     style beats the stylesheet's data-slice rule, so consumer overrides of
     --color-{key} at any ancestor recolor that series. Non-series-keyed
     elements (pie/donut/segments categorical slots) keep data-slice only
     and are coloured by the stylesheet. */
  #seriesFill(slotIdx, seriesKey) {
    if (!seriesKey) return ` data-slice="${slotIdx}"`;
    return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="fill: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
  }
  #seriesStroke(slotIdx, seriesKey) {
    if (!seriesKey) return ` data-slice="${slotIdx}"`;
    return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="stroke: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
  }

  /* ── Tooltip ───────────────────────────────────────────────────── */

  #tipEl = null;

  disconnected() {
    this.#resizeObs?.disconnect();
    this.#resizeObs = null;
    if (this.#resizeRaf) { cancelAnimationFrame(this.#resizeRaf); this.#resizeRaf = null; }
    document.removeEventListener('toggle', this.#onLegendToggle);
    document.removeEventListener('pointerdown',   this.#pinnedTouchDismiss);
    this.removeEventListener('keydown', this.#onKeydown);
    this.removeEventListener('focus',   this.#onFocus);
    this.removeEventListener('blur',    this.#onBlur);
    this.removeEventListener('pointerover',  this.#onPointerOver);
    this.removeEventListener('pointermove',  this.#onPointerMove);
    this.removeEventListener('pointerleave', this.#onPointerLeave);
    this.removeEventListener('pointerdown',  this.#onPointerDown);
    this.removeEventListener('click',        this.#onClick);
    this.#hideTooltip();
  }

  /* Detect whether an external chart-legend-ui or tooltip-ui is acting as
     this chart's legend / tooltip via [for=self.id]. When present, the
     internal counterpart is suppressed so we don't double-render. */
  #hasExternalLegend() {
    if (!this.id) return false;
    return !!document.querySelector(`chart-legend-ui[for="${CSS.escape(this.id)}"]`);
  }
  #hasExternalTooltip() {
    if (!this.id) return false;
    return !!document.querySelector(`tooltip-ui[follows="pointer"][for="${CSS.escape(this.id)}"]`);
  }

  #onLegendToggle = (event) => {
    const legend = event.target?.closest?.('chart-legend-ui[for]');
    if (!legend || legend.getAttribute('for') !== this.id) return;
    const { key, active } = event.detail || {};
    if (!key) return;
    if (active) this.#hiddenSeriesKeys.delete(key);
    else        this.#hiddenSeriesKeys.add(key);
    this.#renderChart();
  };

  #isSeriesHidden(key) {
    return !!key && this.#hiddenSeriesKeys.has(key);
  }

  /* ── Number formatting (OD-CHART-03) ────────────────────────────
     `format` attribute selects how datum values are displayed on axis
     labels, bar/line value overlays, donut centers, etc. Falls back to
     the local `fmt()` helper for `abbr` (existing behavior). Currency
     prefix reads --chart-currency-prefix (default "$") so consumers
     retune per locale without touching the format attr itself. */
  #fmtValue(v) {
    if (v == null || v === '') return '';
    const n = +v;
    if (!Number.isFinite(n)) return String(v);
    const fmtAttr = this.format || 'abbr';
    switch (fmtAttr) {
      case 'decimal': return n.toFixed(2);
      case 'percent': return `${(n * 100).toFixed(1)}%`;
      case 'currency': {
        /* --chart-currency-prefix is a CSS custom property storing a string.
           getComputedStyle returns it with the surrounding quotes ('"$"'),
           which must be stripped before concatenation. Empty/unset falls
           back to '$'. */
        let prefix = getComputedStyle(this).getPropertyValue('--chart-currency-prefix').trim();
        if ((prefix.startsWith('"') && prefix.endsWith('"')) ||
            (prefix.startsWith("'") && prefix.endsWith("'"))) {
          prefix = prefix.slice(1, -1);
        }
        return `${prefix || '$'}${fmt(n)}`;
      }
      case 'abbr':
      default: return fmt(n);
    }
  }

  /* ── Keyboard nav (OD-CHART-06) ─────────────────────────────────
     Virtual focus across data points in DOM order — ArrowLeft/Right or
     ArrowUp/Down step by one; Home/End jump to first/last; Enter/Space
     fires chart-select; Escape clears focus and fires chart-leave. On
     focus change, the chart emits the same chart-hover event shape as
     the pointer path so tooltip-ui[follows=pointer][for] tracks keyboard
     focus without needing its own code path.

     Per-datum focus indicator is a data-a11y-focus attribute on the
     focused element + a data-a11y-focused attribute on the host; CSS
     paints the outline. */
  #focusedDatumIdx = -1;

  #datums() {
    /* `[data-tip-label]` OR `[data-tip-value]` — every datum emits at
       least one, so this catches every renderer. `circle[data-hit]` is
       included intentionally (line/scatter uses a hit overlay as its
       hover target). */
    return Array.from(this.querySelectorAll('[data-tip-label], [data-tip-value]'));
  }

  #setFocusedDatum(idx) {
    const datums = this.#datums();
    if (!datums.length) { this.#clearFocusedDatum(); return; }
    this.#focusedDatumIdx = Math.max(0, Math.min(idx, datums.length - 1));
    this.#paintFocusIndicator();
    this.#emitHoverForFocused();
  }

  #paintFocusIndicator() {
    const datums = this.#datums();
    /* Clear previous focus marker. */
    for (const d of datums) d.removeAttribute('data-a11y-focus');
    const el = datums[this.#focusedDatumIdx];
    if (!el) return this.removeAttribute('data-a11y-focused');
    el.setAttribute('data-a11y-focus', '');
    this.setAttribute('data-a11y-focused', '');
  }

  #clearFocusedDatum() {
    this.#focusedDatumIdx = -1;
    for (const d of this.#datums()) d.removeAttribute('data-a11y-focus');
    this.removeAttribute('data-a11y-focused');
  }

  #emitHoverForFocused() {
    const el = this.#datums()[this.#focusedDatumIdx];
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
    this.#emitHover(el, synthEvent);
  }

  #emitSelectForFocused() {
    const el = this.#datums()[this.#focusedDatumIdx];
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
    this.dispatchEvent(new CustomEvent('chart-select', {
      bubbles: true,
      detail: this.#tipPayload(el, synthEvent),
    }));
  }

  #onFocus = () => {
    /* On first focus, select the first datum. Subsequent focuses keep
       the prior position (common keyboard-nav convention). */
    if (this.#focusedDatumIdx === -1) this.#setFocusedDatum(0);
  };

  #onBlur = () => {
    this.#clearFocusedDatum();
    if (this.#hoveredTarget) this.#emitLeave();
  };

  #onKeydown = (e) => {
    const datums = this.#datums();
    if (!datums.length) return;
    switch (e.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        e.preventDefault();
        this.#setFocusedDatum(this.#focusedDatumIdx + 1);
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        e.preventDefault();
        this.#setFocusedDatum(Math.max(0, this.#focusedDatumIdx - 1));
        break;
      case 'Home':
        e.preventDefault();
        this.#setFocusedDatum(0);
        break;
      case 'End':
        e.preventDefault();
        this.#setFocusedDatum(datums.length - 1);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        this.#emitSelectForFocused();
        break;
      case 'Escape':
        e.preventDefault();
        this.#clearFocusedDatum();
        if (this.#hoveredTarget) this.#emitLeave();
        break;
    }
  };

  #hoveredTarget = null;

  #onPointerOver = (e) => {
    /* OD-CHART-07 — touch uses pointerdown to tap-to-pin; pointerover
       is too noisy on touch devices (fires redundantly with down). */
    if (e.pointerType === 'touch') return;
    const t = e.target.closest('[data-tip-label], [data-tip-value]');
    if (t) {
      this.#showTooltip(t, e);
      this.#emitHover(t, e);
    }
  };

  #onPointerMove = (e) => {
    if (e.pointerType === 'touch') return;  /* touch handled via pointerdown */
    const t = e.target.closest('[data-tip-label], [data-tip-value]');
    if (!t) {
      if (this.#hoveredTarget) this.#emitLeave();
      return this.#hideTooltip();
    }
    this.#showTooltip(t, e);
    if (t !== this.#hoveredTarget) this.#emitHover(t, e);
  };

  #onPointerLeave = (e) => {
    if (e && e.pointerType === 'touch') return;
    this.#hideTooltip();
    if (this.#hoveredTarget) this.#emitLeave();
  };

  /* OD-CHART-07 — tap-to-pin on touch devices. Tapping a datum shows
     the tooltip and emits chart-hover; tapping elsewhere (including
     outside the chart via the document listener below) dismisses it.
     Mouse + pen go through the existing pointerover path; pointerdown
     here only processes touch so desktop behavior is unchanged. */
  #onPointerDown = (e) => {
    if (e.pointerType !== 'touch') return;
    const t = e.target.closest('[data-tip-label], [data-tip-value]');
    if (!t) {
      /* Tap on empty plot area → dismiss any pinned tooltip. */
      if (this.#hoveredTarget) this.#emitLeave();
      return this.#hideTooltip();
    }
    this.#showTooltip(t, e);
    if (t !== this.#hoveredTarget) this.#emitHover(t, e);
    /* Attach document-level dismiss — removed when tap lands outside
       the chart. Idempotent: re-attaching replaces the same listener
       by reference. */
    document.addEventListener('pointerdown', this.#pinnedTouchDismiss);
  };

  /* Document-level touch listener — dismiss pinned tooltip when the
     user taps outside the chart. Only attached when a tooltip is
     currently pinned via touch. Lazily attached/detached to avoid
     perpetual document listeners on every page. */
  #pinnedTouchDismiss = (e) => {
    if (e.pointerType !== 'touch') return;
    if (!this.contains(e.target)) {
      this.#hideTooltip();
      if (this.#hoveredTarget) this.#emitLeave();
      document.removeEventListener('pointerdown', this.#pinnedTouchDismiss);
    }
  };

  #onClick = (e) => {
    const t = e.target.closest('[data-tip-label], [data-tip-value]');
    if (!t) return;
    this.dispatchEvent(new CustomEvent('chart-select', {
      bubbles: true,
      detail: this.#tipPayload(t, e),
    }));
  };

  #emitHover(target, event) {
    this.#hoveredTarget = target;
    this.dispatchEvent(new CustomEvent('chart-hover', {
      bubbles: true,
      detail: this.#tipPayload(target, event),
    }));
  }

  #emitLeave() {
    this.#hoveredTarget = null;
    this.dispatchEvent(new CustomEvent('chart-leave', { bubbles: true }));
  }

  /* Build the standard event payload from a hovered/clicked datum element.
     OD-CHART-05 — per-X-column granularity. For multi-series renderers
     (stacked-bar, grouped-bar, multi-line, composed), the pointer is
     logically over "one X-axis column" and all series at that X are
     hover-relevant. The detail therefore carries:
       - top-level fields describing the specific datum the pointer
         actually entered (label, value, pct, series, slot) — back-compat
         with Phase 1-2 consumers.
       - `payload` array with every series at this X column, each row
         shaped { series, value, pct?, slot }. For single-series types,
         payload has one entry containing the top-level datum.
     Tooltip-ui[follows=pointer] renders one row per payload entry so
     the card shows all series at that X, highlighting the hovered
     series slightly. */
  #tipPayload(target, event) {
    const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;
    const slot = target.dataset.slice != null ? Number(target.dataset.slice) : null;
    const value = tipValue != null ? Number(tipValue) : null;
    const pct = tipPct != null ? Number(tipPct) : null;

    const hoveredLabel = tipLabel ?? null;
    const hoveredSeries = tipSeries ?? null;

    /* Build the per-X-column payload. Look up the data row whose x-key
       matches the hovered label; enumerate declared y-keys; project a
       row per (visible) series. For types without an x-key (pie/donut/
       segments/radar — categorical) or with no y-keys, payload falls
       back to a single entry matching the hovered datum. */
    const payload = this.#buildXColumnPayload(hoveredLabel, hoveredSeries) ?? [{
      series: hoveredSeries,
      label:  hoveredLabel,
      value:  Number.isFinite(value) ? value : (tipValue ?? null),
      pct:    Number.isFinite(pct) ? pct : null,
      slot,
    }];

    return {
      label:   hoveredLabel,
      value:   Number.isFinite(value) ? value : (tipValue ?? null),
      pct:     Number.isFinite(pct) ? pct : null,
      series:  hoveredSeries,
      slot,
      payload,
      pointerX: event?.clientX ?? null,
      pointerY: event?.clientY ?? null,
    };
  }

  #buildXColumnPayload(xLabel, hoveredSeries) {
    if (xLabel == null) return null;
    const xKey = this.x;
    const yKeys = this.#yKeys();
    if (!xKey || yKeys.length === 0) return null;
    /* Find the row whose x-key value matches the hovered label. Raw
       label string match — both sides came from the same source so no
       type coercion needed. */
    const row = this.#data.find(d => String(d[xKey] ?? '') === String(xLabel));
    if (!row) return null;
    const visible = yKeys.filter(k => !this.#isSeriesHidden(k));
    /* Single-series types don't emit `data-tip-series` — the hovered
       datum is implicitly the one and only series. Promote it so the
       tooltip can still emphasize the row. */
    const effectiveHovered = hoveredSeries || (visible.length === 1 ? visible[0] : null);
    return visible.map((k, i) => {
      const v = +(row[k] ?? 0);
      return {
        series: k,
        label:  xLabel,
        value:  Number.isFinite(v) ? v : null,
        pct:    null,
        slot:   i % 10,
        hovered: k === effectiveHovered,
      };
    });
  }

  #showTooltip(target, event) {
    /* Phase 2 follow-up — when an external tooltip-ui[follows=pointer][for=self]
       is present on the page, it owns the tooltip affordance. Skip the
       internal #tipEl entirely to avoid double-render / fighting for the
       top-layer. The external tooltip subscribes to chart-hover events
       emitted in #emitHover below. */
    if (this.#hasExternalTooltip()) return;

    const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;

    if (!this.#tipEl) {
      const el = document.createElement('div');
      el.setAttribute('popover', 'manual');
      el.setAttribute('role', 'tooltip');
      el.classList.add('chart-tooltip-popup');
      document.body.appendChild(el);
      this.#tipEl = el;
    }

    const lines = [];
    if (tipSeries) lines.push(`<span data-tip-role="series">${esc(tipSeries)}</span>`);
    if (tipLabel)  lines.push(`<span data-tip-role="label">${esc(tipLabel)}</span>`);
    if (tipValue !== undefined) {
      const pct = tipPct !== undefined ? ` <span data-tip-role="pct">(${tipPct}%)</span>` : '';
      lines.push(`<span data-tip-role="value">${this.#fmtValue(tipValue)}${pct}</span>`);
    }
    this.#tipEl.innerHTML = lines.join('');

    try { this.#tipEl.showPopover(); } catch (_) { /* popover not supported */ }

    /* Follow the cursor — centered horizontally above, clamp to viewport
       with an 8px edge-pad, flip below when there's no room above. */
    const gap = 12;
    const edgePad = 8;
    const { clientX, clientY } = event;
    const tw = this.#tipEl.offsetWidth  || 0;
    const th = this.#tipEl.offsetHeight || 0;
    let x = clientX - tw / 2;
    let y = clientY - th - gap;
    if (x < edgePad)                         x = edgePad;
    if (x + tw > window.innerWidth - edgePad) x = window.innerWidth - tw - edgePad;
    if (y < edgePad)                         y = clientY + gap;
    this.#tipEl.style.left = `${x}px`;
    this.#tipEl.style.top  = `${y}px`;
  }

  #hideTooltip() {
    if (!this.#tipEl) return;
    try { this.#tipEl.hidePopover(); } catch (_) { /* */ }
    this.#tipEl.remove();
    this.#tipEl = null;
  }

  /* ── Y keys helper ────────────────────────────────────────────── */

  #yKeys() {
    return this.y ? this.y.split(',').map(k => k.trim()).filter(Boolean) : [];
  }

  /* ── Grid + axes helper ───────────────────────────────────────── */

  #gridAndAxes(width, height, ticks, labels, pad, dims) {
    const p = pad;
    const fs = dims?.fontSize || 10;
    const ls = dims?.labelSize || fs;
    let s = '';

    // Reduce tick count at small sizes
    let displayTicks = ticks;
    if (dims?.sizeClass === 'sm' && ticks.length > 4) {
      displayTicks = ticks.filter((_, i) => i % 2 === 0 || i === ticks.length - 1);
    }

    // hideGrid suppresses both gridlines AND axis labels — callers who
    // want labels without gridlines can omit hide-grid and rely on token
    // overrides to make gridlines transparent. This keeps compact in-card
    // charts visually clean.
    if (!this.hideGrid) {
      const tickRange = ticks[ticks.length - 1] - ticks[0];
      const safeRange = tickRange || 1; // Prevent division by zero
      for (const t of displayTicks) {
        const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
        s += `<line data-grid x1="${p.left}" y1="${gy}" x2="${width - p.right}" y2="${gy}"/>`;
      }

      /* Y-axis labels */
      for (const t of displayTicks) {
        const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
        s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
      }

      /* X-axis labels — stride based on label width so they never overlap */
      if (labels) {
        const plotW = width - p.left - p.right;
        const step = plotW / labels.length;
        const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
        const labelPx = maxChars * ls * 0.6 + ls * 0.75;
        const stride = Math.max(1, Math.ceil(labelPx / step));
        const last = labels.length - 1;
        for (let i = 0; i < labels.length; i++) {
          if (i % stride !== 0 && i !== last) continue;
          if (i !== last && last - i < stride) continue;
          const lx = p.left + step * i + step / 2;
          s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${esc(labels[i])}</text>`;
        }
      }
    }

    return s;
  }

  /* ── Bar chart ────────────────────────────────────────────────── */

  #renderBar() {
    const dims = this.#dims();
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(v => +(v[yKey] ?? 0));
    const labels = data.map(v => v[this.x] ?? '');
    const ticks = niceScale(0, Math.max(...vals), 5);
    const maxVal = ticks[ticks.length - 1];

    const { width, height, pad } = dims;
    const plotH = height - pad.top - pad.bottom;
    const plotW = width - pad.left - pad.right;
    const barW = plotW / data.length;
    const barInner = barW * 0.6;
    const barGap = (barW - barInner) / 2;

    let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);

    // At sm auto-hide value labels (they overlap at narrow widths).
    const showValues = !this.hideValues && dims.sizeClass !== 'sm';
    const showAverage = !this.hideAverage && vals.length > 1 && dims.sizeClass !== 'sm' && !this.hideGrid;

    for (let i = 0; i < data.length; i++) {
      const v = vals[i];
      const barH = maxVal ? (v / maxVal) * plotH : 0;
      const bx = pad.left + barW * i + barGap;
      const by = pad.top + plotH - barH;

      svg += `<path data-bar${tip({ label: labels[i], value: v })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;

      if (showValues) {
        svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
      }
    }

    if (showAverage) {
      const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
      const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
      svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
      svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
      /* Wider invisible hit target so the thin dashed line is hoverable */
      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"/>`;
    }

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Line chart ───────────────────────────────────────────────── */

  #renderLine() {
    const dims = this.#dims();
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(v => +(v[yKey] ?? 0));
    const labels = data.map(v => v[this.x] ?? '');
    const ticks = niceScale(0, Math.max(...vals), 5);
    const maxVal = ticks[ticks.length - 1];

    const { width, height, pad } = dims;
    const plotH = height - pad.top - pad.bottom;
    const plotW = width - pad.left - pad.right;
    const step = plotW / Math.max(data.length - 1, 1);

    let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);

    const points = vals.map((v, i) => {
      const px = pad.left + step * i;
      const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
      return { x: px, y: py, v, label: labels[i] };
    });

    const baseline = pad.top + plotH;
    const t = Math.max(0, Math.min(1, this.smooth));
    svg += `<path data-area d="${smoothAreaPath(points, baseline, t)}"/>`;
    svg += `<path data-line d="${smoothPath(points, t)}"/>`;

    // Density tuning for sm: smaller dots, no value labels.
    const isSm = dims.sizeClass === 'sm';
    const dotR = isSm ? 1.5 : 3;
    const hitR = Math.max(dotR, 10);  // invisible hit-target for tooltip
    const showValues = !this.hideValues && !isSm;
    const showAverage = !this.hideAverage && vals.length > 1 && !isSm && !this.hideGrid;

    for (const p of points) {
      svg += `<circle data-dot cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
      svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
      if (showValues) {
        svg += `<text data-value x="${p.x}" y="${p.y - 8}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(p.v)}</text>`;
      }
    }

    if (showAverage) {
      const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
      const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
      svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
      svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
      /* Wider invisible hit target so the thin dashed line is hoverable */
      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"/>`;
    }

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Pie chart ────────────────────────────────────────────────── */

  #renderPie() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const total = vals.reduce((a, b) => a + b, 0) || 1;
    const labels = data.map(d => d[this.x] ?? '');

    // Responsive viewBox so pie respects chart-ui's max-height and
    // doesn't push the legend past the container bounds.
    const dims = this.#dims();
    const { width, height } = dims;
    const cx = width / 2;
    const cy = height / 2;
    const r = Math.max(30, Math.min(width, height) * 0.42);

    let svg = '';
    let angle = -Math.PI / 2;

    for (let i = 0; i < vals.length; i++) {
      const slice = (vals[i] / total) * Math.PI * 2;
      if (slice === 0) { continue; }
      const end = angle + slice;

      const pct = ((vals[i] / total) * 100).toFixed(1);
      const attrs = ` data-slice="${i % 10}"${tip({ label: labels[i], value: vals[i], pct })}`;

      if (Math.abs(slice - Math.PI * 2) < 0.001) {
        /* Full circle — special case */
        svg += `<circle${attrs} cx="${cx}" cy="${cy}" r="${r}"/>`;
      } else {
        svg += `<path${attrs} d="${arcPath(cx, cy, r, angle, end)}"/>`;
      }
      angle = end;
    }

    this.#legendData = data.map((d, i) => ({
      label: labels[i],
      value: vals[i],
      pct: ((vals[i] / total) * 100).toFixed(1),
      slot: i % 10,
    }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Donut chart ──────────────────────────────────────────────── */

  #renderDonut() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const total = vals.reduce((a, b) => a + b, 0) || 1;
    const labels = data.map(d => d[this.x] ?? '');

    const dims = this.#dims();
    const { width, height } = dims;
    const cx = width / 2;
    const cy = height / 2;
    const outer = Math.max(30, Math.min(width, height) * 0.42);
    const inner = outer * 0.72;

    let svg = '';
    let angle = -Math.PI / 2;

    for (let i = 0; i < vals.length; i++) {
      const slice = (vals[i] / total) * Math.PI * 2;
      if (slice === 0) continue;
      const end = angle + slice;

      const pct = ((vals[i] / total) * 100).toFixed(1);
      const attrs = ` data-slice="${i % 10}"${tip({ label: labels[i], value: vals[i], pct })}`;

      if (Math.abs(slice - Math.PI * 2) < 0.001) {
        /* Full ring */
        svg += `<circle${attrs} cx="${cx}" cy="${cy}" r="${(outer + inner) / 2}" fill="none" stroke-width="${outer - inner}" style="fill:none"/>`;
        svg += `<path${attrs} d="M ${cx - outer} ${cy} A ${outer} ${outer} 0 1 1 ${cx + outer} ${cy} A ${outer} ${outer} 0 1 1 ${cx - outer} ${cy} Z M ${cx - inner} ${cy} A ${inner} ${inner} 0 1 0 ${cx + inner} ${cy} A ${inner} ${inner} 0 1 0 ${cx - inner} ${cy} Z" fill-rule="evenodd"/>`;
      } else {
        svg += `<path${attrs} d="${donutArcPath(cx, cy, outer, inner, angle, end, this.#resolveRadius())}"/>`;
      }
      angle = end;
    }

    /* Center total — font size tied to donut radius so it scales with the chart */
    const totalFs = Math.max(14, Math.round(outer * 0.32));
    const labelFs = Math.max(9, Math.round(outer * 0.16));
    svg += `<text data-donut-total x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(total)}</text>`;
    svg += `<text data-donut-label x="${cx}" y="${cy + totalFs}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">Total</text>`;

    this.#legendData = data.map((d, i) => ({
      label: labels[i],
      value: vals[i],
      pct: ((vals[i] / total) * 100).toFixed(1),
      slot: i % 10,
    }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Radar chart ──────────────────────────────────────────────── */

  #renderRadar() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const labels = data.map(d => d[this.x] ?? '');
    const maxVal = Math.max(...vals) || 1;
    const n = data.length;

    // Derive size from container so viewBox ≈ rendered pixels (keeps
    // font-size and stroke widths at their intended visual scale).
    const dims = this.#dims();
    const { width, height, fontSize } = dims;
    const cx = width / 2;
    const cy = height / 2;
    // Leave label breathing room proportional to font size so labels
    // never clip at wide sizes or crowd the polygon at small sizes.
    const labelPad = fontSize * 3.5;
    const r = Math.max(40, Math.min(width, height) / 2 - labelPad);

    let svg = '';
    const angleStep = (Math.PI * 2) / n;

    /* Grid rings (3 levels) */
    for (let level = 1; level <= 3; level++) {
      const lr = (r * level) / 3;
      let ring = '';
      for (let i = 0; i < n; i++) {
        const a = -Math.PI / 2 + angleStep * i;
        const px = cx + lr * Math.cos(a);
        const py = cy + lr * Math.sin(a);
        ring += (i === 0 ? 'M' : 'L') + ` ${px} ${py}`;
      }
      ring += ' Z';
      svg += `<path data-grid d="${ring}"/>`;
    }

    /* Axis lines */
    for (let i = 0; i < n; i++) {
      const a = -Math.PI / 2 + angleStep * i;
      const px = cx + r * Math.cos(a);
      const py = cy + r * Math.sin(a);
      svg += `<line data-grid x1="${cx}" y1="${cy}" x2="${px}" y2="${py}"/>`;
    }

    /* Data polygon + per-vertex hit targets */
    let poly = '';
    const vertices = [];
    for (let i = 0; i < n; i++) {
      const a = -Math.PI / 2 + angleStep * i;
      const vr = (vals[i] / maxVal) * r;
      const px = cx + vr * Math.cos(a);
      const py = cy + vr * Math.sin(a);
      poly += (i === 0 ? 'M' : 'L') + ` ${px} ${py}`;
      vertices.push({ px, py });
    }
    poly += ' Z';
    svg += `<path data-radar-fill d="${poly}"/>`;
    svg += `<path data-radar-line d="${poly}"/>`;

    const hitR = Math.max(fontSize, 10);
    for (let i = 0; i < n; i++) {
      const { px, py } = vertices[i];
      svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
    }

    /* Labels */
    const labelGap = fontSize * 1.3;
    for (let i = 0; i < n; i++) {
      const a = -Math.PI / 2 + angleStep * i;
      const lx = cx + (r + labelGap) * Math.cos(a);
      const ly = cy + (r + labelGap) * Math.sin(a);
      const anchor = Math.abs(Math.cos(a)) < 0.1 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';
      svg += `<text data-x-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${fontSize}">${esc(labels[i])}</text>`;
    }

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Sparkline ────────────────────────────────────────────────── */

  #renderSparkline() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const maxVal = Math.max(...vals) || 1;
    const minVal = Math.min(...vals);
    const range = maxVal - minVal || 1;

    // Use the actual container dims so the aspect ratio matches what
    // the CSS allocates (otherwise the fixed 120×32 viewBox gets
    // stretched vertically in tall in-card slots).
    const containerW = this.clientWidth  || 120;
    const containerH = this.clientHeight || 32;
    const w = Math.max(40, containerW);
    const h = Math.max(16, containerH);
    const step = w / Math.max(vals.length - 1, 1);

    // Breathing room so the line doesn't clip at the top/bottom edges.
    const padY = Math.max(2, Math.min(6, h * 0.1));

    const labels = data.map(d => d[this.x] ?? '');
    const points = vals.map((v, i) => ({
      x: step * i,
      y: h - ((v - minVal) / range) * (h - padY * 2) - padY,
      v,
      label: labels[i],
    }));

    const t = Math.max(0, Math.min(1, this.smooth));
    let svg = '';
    svg += `<path data-area d="${smoothAreaPath(points, h, t)}"/>`;
    svg += `<path data-line d="${smoothPath(points, t)}"/>`;

    /* Invisible hit-targets for tooltip — sized to fill each X-column */
    const hitR = Math.max(step / 2, 6);
    for (const p of points) {
      svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
    }

    return { svg, viewBox: `0 0 ${w} ${h}` };
  }

  /* ── Area chart (filled line) ────────────────────────────────────
     Single-series axis chart emphasizing the filled region under the
     curve. Shares all infrastructure with #renderLine() — CSS rules
     scoped to :scope[type="area"] override the area-fill opacity and
     the line treatment to make the region dominant. Output is DOM-
     compatible with #renderLine() so legend / tooltip / events work
     identically. */
  #renderArea() {
    return this.#renderLine();
  }

  /* ── Scatter (points only, no connecting line) ──────────────────
     Two-dimensional distribution — each datum is a dot positioned by
     (x, y). Unlike #renderLine(), no connecting path is emitted. The
     x-key is typically also numeric; when it's a category label, the
     dots land at category-indexed column centers. */
  #renderScatter() {
    const dims = this.#dims();
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(v => +(v[yKey] ?? 0));
    const labels = data.map(v => v[this.x] ?? '');
    const ticks = niceScale(0, Math.max(...vals), 5);
    const maxVal = ticks[ticks.length - 1];

    const { width, height, pad } = dims;
    const plotH = height - pad.top - pad.bottom;
    const plotW = width - pad.left - pad.right;
    const step = plotW / Math.max(data.length - 1, 1);

    let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);

    const dotR = dims.sizeClass === 'sm' ? 2.5 : 4;
    const hitR = Math.max(dotR * 2, 10);

    for (let i = 0; i < vals.length; i++) {
      const px = pad.left + step * i;
      const py = pad.top + plotH - (maxVal ? (vals[i] / maxVal) * plotH : 0);
      svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
      svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
    }

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Radial bar (concentric rings, each ring one datum) ─────────
     Each datum gets its own ring in a concentric stack. The ring's
     sweep angle is proportional to value/max — e.g., v=max is a full
     ring, v=0 is nothing. Rings are clipped to a common max-radius
     track shown as a faint backing arc so empty values read visually.
     Center is empty — authors who want a value overlay use the
     `[slot=empty]` trick or compose in a sibling element. */
  #renderRadialBar() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const labels = data.map(d => d[this.x] ?? '');
    const maxVal = Math.max(...vals) || 1;

    const dims = this.#dims();
    const { width, height } = dims;
    const cx = width / 2;
    const cy = height / 2;
    const outerR = Math.max(30, Math.min(width, height) * 0.45);
    const innerR = outerR * 0.3;
    const ringCount = vals.length || 1;
    const bandW = (outerR - innerR) / ringCount;
    const gap = Math.min(2, bandW * 0.15);

    let svg = '';

    /* Backing tracks + filled arcs per datum, from inner to outer.
       Uses the stroke-dasharray technique: each ring is a single <circle>,
       rotated so the dash starts at 12 o'clock, with dasharray sized to
       (filled, remainder). Avoids the "full circle" vs "arc path" branch
       that produced rendering artifacts at 100% fills. */
    for (let i = 0; i < vals.length; i++) {
      const r0 = innerR + bandW * i + gap / 2;
      const r1 = innerR + bandW * (i + 1) - gap / 2;
      const mid = (r0 + r1) / 2;
      const thickness = r1 - r0;
      const circumference = 2 * Math.PI * mid;
      const filled = Math.max(0, Math.min(1, vals[i] / maxVal)) * circumference;

      /* Backing ring — full circle, faint stroke */
      svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;

      if (filled <= 0) continue;

      const pct = ((vals[i] / maxVal) * 100).toFixed(1);
      const tipAttrs = tip({ label: labels[i], value: vals[i], pct });

      /* Filled arc — stroke-dasharray splits the circumference into
         (drawn, gap). Rotated -90° around the center so the dash begins
         at 12 o'clock and sweeps clockwise. stroke-linecap="butt" for
         full rings (so the ends don't overlap into a wedge artifact);
         "round" for partial arcs so ends read as bar caps. */
      const isFull = Math.abs(filled - circumference) < 0.5;
      const linecap = isFull ? 'butt' : 'round';
      const dashArray = isFull
        ? `${circumference} 0`
        : `${filled} ${circumference - filled}`;

      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})"/>`;
    }

    this.#legendData = data.map((d, i) => ({
      label: labels[i],
      key: labels[i],
      value: vals[i],
      pct: ((vals[i] / maxVal) * 100).toFixed(1),
      slot: i % 10,
    }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Gauge (half-donut with center value) ───────────────────────
     Common KPI visualization: a 180° arc filled from the leftmost
     point (9 o'clock) clockwise to the current value. Center shows
     the value as a large number. Uses the same data-slice fill path
     as pie/donut for theme coherence. Accepts a single datum OR
     sums all data values — max is `maxVal` prop if set, otherwise
     taken as the sum of all values (treats the total as 100%). */
  #renderGauge() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const sum = vals.reduce((a, b) => a + b, 0) || 1;

    /* Gauge reads a single value + optional max from the first datum
       (v, max?) OR treats the first value as the numerator and the
       sum as the denominator for "X of Y" style metrics. */
    const primary = vals[0] ?? 0;
    const maxVal = data[0]?.max != null ? +data[0].max : (vals.length === 1 ? Math.max(primary, 1) : sum);
    const pct = Math.max(0, Math.min(1, primary / maxVal));

    const dims = this.#dims();
    const { width, height } = dims;

    /* Place the arc's visual center so the half-circle uses the full
       width below the value label. cy sits lower so the half-arc has
       room for the big center label above it. */
    const cx = width / 2;
    const cy = height * 0.68;
    const outerR = Math.max(40, Math.min(width * 0.45, height * 0.6));
    const innerR = outerR * 0.72;
    /* End-cap radius — fully-rounded pill ends matching the
       progress-ui convention (--progress-radius: var(--a-radius-full)).
       donutArcPath clamps the corner radius to (outerR - innerR) / 2,
       so passing the ring half-thickness gives full pill caps on both
       ends of the arc. */
    const capR = (outerR - innerR) / 2;

    let svg = '';

    /* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
       clockwise through 12 o'clock (3π/2) to 3 o'clock (2π). donutArcPath
       produces a filled ring wedge; with start=π, end=2π the sliceAngle
       equals π so large-arc-flag=0 and the arc passes over the top. */
    svg += `<path data-radial-track d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, 2 * Math.PI, capR)}"/>`;

    /* Filled portion — 0..180° proportional to pct. pct=0 draws nothing;
       pct=1 overlays the full backing arc. */
    if (pct > 0) {
      const fillEnd = Math.PI + Math.PI * pct;
      const tipAttrs = tip({ label: data[0]?.[this.x] ?? 'Value', value: primary, pct: (pct * 100).toFixed(1) });
      svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, capR)}"/>`;
    }

    /* Center value label */
    const totalFs = Math.max(18, Math.round(outerR * 0.42));
    const labelFs = Math.max(10, Math.round(outerR * 0.2));
    const labelY = cy - outerR * 0.15;
    svg += `<text data-gauge-value x="${cx}" y="${labelY}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
    if (maxVal !== primary) {
      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>`;
    }

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Funnel (stage drop-off) ───────────────────────────────────
     Classic conversion funnel: stages listed top-to-bottom, each
     rendered as a trapezoid whose width shrinks proportional to its
     value. Stage labels live to the left, value + pct vs first stage
     to the right. Typical use: sales pipeline, signup funnel.

     Trapezoid geometry: for stage i with value v_i and max value v_0:
       halfWidthTop  = (v_i / v_max) * plotW / 2
       halfWidthBot  = (v_{i+1} / v_max) * plotW / 2
     Last stage uses halfWidthBot = halfWidthTop (degrades to rect). */
  #renderFunnel() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const labels = data.map(d => d[this.x] ?? '');
    const n = vals.length;
    if (n === 0) return { svg: '', viewBox: '0 0 100 100' };

    const maxVal = Math.max(...vals) || 1;

    const dims = this.#dims();
    const { width, height } = dims;
    const fs = dims.fontSize;
    const padX = Math.max(width * 0.18, 80);      /* room for stage labels */
    const padY = fs * 0.8;
    const plotW = width - padX * 2;
    const plotH = height - padY * 2;
    const stageH = plotH / n;
    const gap = Math.max(2, stageH * 0.08);

    const cx = width / 2;

    let svg = '';
    for (let i = 0; i < n; i++) {
      const vTop = vals[i];
      const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
      const halfTop = (vTop / maxVal) * (plotW / 2);
      const halfBot = (vBot / maxVal) * (plotW / 2);
      const y0 = padY + stageH * i + gap / 2;
      const y1 = padY + stageH * (i + 1) - gap / 2;

      const pct = ((vTop / maxVal) * 100).toFixed(1);
      const tipAttrs = tip({ label: labels[i], value: vTop, pct });

      /* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
      const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
      svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;

      /* Stage label — left-aligned outside the funnel */
      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>`;

      /* Value + pct — right-aligned outside the funnel */
      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>`;
      if (i > 0) {
        const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
        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>`;
      }
    }

    this.#legendData = data.map((d, i) => ({
      label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
    }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Treemap (hierarchical rect tiling) ────────────────────────
     Flat squarified treemap: each datum is a rect whose area is
     proportional to its value. Not nested — Phase 5 ships flat
     rectangles; a nested variant (children arrays) is a natural
     future extension.

     Uses the Bruls/Huijbregts/Van Wijk squarified algorithm adapted
     for single-level data: sorts values desc, packs rows that hold
     aspect ratios close to 1, alternating row direction based on
     remaining container aspect. */
  #renderTreemap() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const labels = data.map(d => d[this.x] ?? '');
    const n = vals.length;
    if (n === 0) return { svg: '', viewBox: '0 0 100 100' };

    const dims = this.#dims();
    const { width, height, fontSize } = dims;
    const total = vals.reduce((a, b) => a + b, 0) || 1;

    /* Sort indices by value desc — squarified needs sorted input but
       we keep original indices for color/label/hit mapping. */
    const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);

    const rects = [];  /* {i, x, y, w, h} in plot coords */

    /* Scale areas so they fill the container. */
    const area = width * height;
    const scaled = order.map(i => (vals[i] / total) * area);

    /* Squarified packer — iterative rows. */
    let x = 0, y = 0, w = width, h = height;
    let row = [];
    let rowStart = 0;

    const worst = (row, width) => {
      if (row.length === 0) return Infinity;
      const sum = row.reduce((a, b) => a + b, 0);
      const max = Math.max(...row);
      const min = Math.min(...row);
      const wsq = width * width;
      const ssq = sum * sum;
      return Math.max((wsq * max) / ssq, ssq / (wsq * min));
    };

    const layoutRow = (row, rowStart, x, y, w, h) => {
      const sum = row.reduce((a, b) => a + b, 0);
      const horizontal = w >= h;
      const rowH = horizontal ? sum / w : h;
      const rowW = horizontal ? w : sum / h;

      let offset = 0;
      for (let k = 0; k < row.length; k++) {
        const frac = row[k] / sum;
        const idx = order[rowStart + k];
        if (horizontal) {
          const segW = frac * w;
          rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
          offset += segW;
        } else {
          const segH = frac * h;
          rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
          offset += segH;
        }
      }
      if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
      return { x: x + rowW, y, w: w - rowW, h };
    };

    let remaining = scaled.slice();
    while (remaining.length > 0) {
      row = [remaining[0]];
      rowStart = scaled.length - remaining.length;
      const shortSide = Math.min(w, h);
      let i = 1;
      while (i < remaining.length) {
        const next = row.concat(remaining[i]);
        if (worst(next, shortSide) > worst(row, shortSide)) break;
        row = next;
        i++;
      }
      const newRect = layoutRow(row, rowStart, x, y, w, h);
      x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
      remaining = remaining.slice(row.length);
    }

    /* Emit rects + labels. */
    let svg = '';
    const pad = 2;
    for (const r of rects) {
      const pct = ((vals[r.i] / total) * 100).toFixed(1);
      const tipAttrs = tip({ label: labels[r.i], value: vals[r.i], pct });
      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()}"/>`;

      /* Three text-placement branches keyed on available tile size:
         - `tall` (h > ~2.5 line heights): label + value stacked, top-aligned
         - `short` (h ≥ ~1.2 line heights but < tall): label only, vertically
           centered — avoids the clipped-text look when tiles are squat
         - `tiny` (h too small for even one line): no text
         All branches gate on width > ~4× fontSize so labels don't overflow. */
      const labelX = r.x + 8;
      const canShowAny = r.w > fontSize * 4;
      const tall  = canShowAny && r.h > fontSize * 2.5;
      const short = canShowAny && !tall && r.h > fontSize * 1.2;

      if (tall) {
        svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(labels[r.i])}</text>`;
        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>`;
      } else if (short) {
        const cy = r.y + r.h / 2;
        svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(labels[r.i])}</text>`;
      }
    }

    this.#legendData = data.map((d, i) => ({
      label: labels[i], value: vals[i], pct: ((vals[i] / total) * 100).toFixed(1), slot: i % 10,
    }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Sankey (flow between source and target nodes) ─────────────
     Flow diagram with columns of nodes and curved bands connecting
     them. Data shape for Sankey differs from other chart types:
       data = [{ source: 'A', target: 'B', value: 10 }, ...]
     Nodes are auto-derived from unique source/target values.
     Lays out two columns (source-left, target-right) with node
     heights proportional to throughput. */
  #renderSankey() {
    const data = this.#data;
    if (!data.length) return { svg: '', viewBox: '0 0 100 100' };

    /* Derive source + target node sets from the link data. */
    const sourceSet = new Map();  /* name → { outflow, y0, y1 } */
    const targetSet = new Map();  /* name → { inflow, y0, y1 } */
    for (const link of data) {
      const s = link.source ?? link.from ?? '';
      const t = link.target ?? link.to ?? '';
      const v = +(link.value ?? link.v ?? 0);
      if (!sourceSet.has(s)) sourceSet.set(s, { name: s, flow: 0 });
      if (!targetSet.has(t)) targetSet.set(t, { name: t, flow: 0 });
      sourceSet.get(s).flow += v;
      targetSet.get(t).flow += v;
    }

    const dims = this.#dims();
    const { width, height, fontSize } = dims;
    const nodeW = Math.max(8, width * 0.03);
    const colPad = nodeW + fontSize * 5;

    const sourceTotal = [...sourceSet.values()].reduce((a, s) => a + s.flow, 0) || 1;
    const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;

    const nodeGap = fontSize * 0.6;

    /* Assign y0/y1 to each source node, stacked top-to-bottom. */
    const sources = [...sourceSet.values()];
    const targets = [...targetSet.values()];
    const sourceTotalH = height - nodeGap * (sources.length - 1);
    const targetTotalH = height - nodeGap * (targets.length - 1);

    let y = 0;
    for (const s of sources) {
      const h = (s.flow / sourceTotal) * sourceTotalH;
      s.y0 = y;
      s.y1 = y + h;
      s.cursor = y;  /* running y for outgoing links */
      y += h + nodeGap;
    }
    y = 0;
    for (const t of targets) {
      const h = (t.flow / targetTotal) * targetTotalH;
      t.y0 = y;
      t.y1 = y + h;
      t.cursor = y;
      y += h + nodeGap;
    }

    let svg = '';

    const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);

    /* Source nodes — left column */
    sources.forEach((s, i) => {
      svg += `<rect data-sankey-node data-slice="${i % 10}" x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
      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>`;
    });

    /* Target nodes — right column */
    targets.forEach((t, i) => {
      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}"/>`;
      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>`;
    });

    /* Links — bezier bands */
    for (const link of data) {
      const s = sourceSet.get(link.source ?? link.from ?? '');
      const t = targetSet.get(link.target ?? link.to ?? '');
      if (!s || !t) continue;
      const v = +(link.value ?? link.v ?? 0);
      if (v <= 0) continue;

      const sH = (v / sourceTotal) * sourceTotalH;
      const tH = (v / targetTotal) * targetTotalH;
      const sTop = s.cursor;
      const sBot = sTop + sH;
      const tTop = t.cursor;
      const tBot = tTop + tH;
      s.cursor += sH;
      t.cursor += tH;

      const x0 = colPad;
      const x1 = width - colPad;
      const mx = (x0 + x1) / 2;
      const tipAttrs = tip({ label: `${s.name} → ${t.name}`, value: v });

      const path = `M ${x0} ${sTop} C ${mx} ${sTop}, ${mx} ${tTop}, ${x1} ${tTop} L ${x1} ${tBot} C ${mx} ${tBot}, ${mx} ${sBot}, ${x0} ${sBot} Z`;
      svg += `<path data-sankey-link${tipAttrs} d="${path}"/>`;
    }

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Composed (bar + line overlay) ─────────────────────────────
     Multi-series combining a bar chart for primary values with a line
     overlay for secondary values. Data shape: each row has the x-key,
     a `bar` key, and a `line` key. Both are axis-aligned against the
     same Y scale (single axis for v1; dual-axis future extension).

     Uses y="bar,line" as the series keys by convention. */
  #renderComposed() {
    const dims = this.#dims();
    const data = this.#data;
    const keys = this.#yKeys();
    const barKey = keys[0] || 'bar';
    const lineKey = keys[1] || 'line';
    const labels = data.map(d => d[this.x] ?? '');
    const barVals = data.map(d => +(d[barKey] ?? 0));
    const lineVals = data.map(d => +(d[lineKey] ?? 0));
    const allVals = [...barVals, ...lineVals];
    const ticks = niceScale(0, Math.max(...allVals), 5);
    const maxVal = ticks[ticks.length - 1];

    const { width, height, pad } = dims;
    const plotH = height - pad.top - pad.bottom;
    const plotW = width - pad.left - pad.right;
    const barW = plotW / data.length;
    const barInner = barW * 0.6;
    const barGap = (barW - barInner) / 2;
    const step = plotW / Math.max(data.length - 1, 1);

    let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);

    /* Bar series (slot 0) */
    if (!this.#isSeriesHidden(barKey)) {
      for (let i = 0; i < data.length; i++) {
        const v = barVals[i];
        const barH = maxVal ? (v / maxVal) * plotH : 0;
        const bx = pad.left + barW * i + barGap;
        const by = pad.top + plotH - barH;
        svg += `<path${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
      }
    }

    /* Line series (slot 1) — anchored at bar center X positions */
    if (!this.#isSeriesHidden(lineKey)) {
      const points = lineVals.map((v, i) => ({
        x: pad.left + barW * i + barW / 2,
        y: pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0),
        v, label: labels[i],
      }));
      const t = Math.max(0, Math.min(1, this.smooth));
      svg += `<path data-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(points, t)}"/>`;
      for (const p of points) {
        svg += `<circle data-dot${this.#seriesFill(1, lineKey)} cx="${p.x}" cy="${p.y}" r="3"/>`;
        svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
      }
    }

    this.#legendData = [
      { label: barKey,  key: barKey,  slot: 0 },
      { label: lineKey, key: lineKey, slot: 1 },
    ];

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Segments (single horizontal stacked bar) ──────────────────
     Categorical data rendered as one horizontal bar split into
     proportional colored slices. Good for compact in-card
     "distribution" widgets. Legend reuses the pie/donut path. */
  #renderSegments() {
    const data = this.#data;
    const yKey = this.#yKeys()[0] || this.y;
    const vals = data.map(d => +(d[yKey] ?? 0));
    const total = vals.reduce((a, b) => a + b, 0) || 1;
    const labels = data.map(d => d[this.x] ?? '');

    // ViewBox matches actual render width so there's no horizontal
    // stretching — keeps rounded corners from flattening into ellipses
    // and matches the token-controlled gap at its intended px size.
    const cs = getComputedStyle(this);
    const w = Math.max(40, this.clientWidth || 200);
    const h = 24;
    const r = Math.min(this.#resolveRadius(), h / 2);
    const gap = Math.max(0, parseFloat(cs.getPropertyValue('--chart-segments-gap')) || 2);

    // Compute segment widths, distributing rounding error so the last
    // segment lands exactly on the right edge.
    const widths = [];
    let acc = 0;
    for (let i = 0; i < vals.length; i++) {
      const nextAcc = (i === vals.length - 1) ? w : Math.round(((acc * total + vals[i] * w) / total));
      // Use the cumulative approach for sub-pixel accuracy.
      const endX = (i === vals.length - 1) ? w : ((vals.slice(0, i + 1).reduce((s, v) => s + v, 0)) / total) * w;
      const startX = (i === 0) ? 0 : ((vals.slice(0, i).reduce((s, v) => s + v, 0)) / total) * w;
      widths.push({ x: startX, w: Math.max(0, endX - startX) });
      acc = nextAcc;
    }

    let svg = '';
    for (let i = 0; i < widths.length; i++) {
      const { x, w: segW } = widths[i];
      if (segW <= 0) continue;

      // Only round the outer corners of the first/last visible segment.
      const isFirst = i === 0;
      const isLast  = i === widths.length - 1;
      const drawW = Math.max(0, segW - (isLast ? 0 : gap));

      const pct = ((vals[i] / total) * 100).toFixed(1);
      const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
      const a11yTitle = `<title>${esc(labels[i])}: ${vals[i]} (${pct}%)</title>`;

      if ((isFirst && isLast) || (!isFirst && !isLast) || r === 0) {
        svg += `<rect data-slice="${i % 10}"${tipAttrs} x="${x}" y="0" width="${drawW}" height="${h}"${(isFirst || isLast) && r ? ` rx="${r}"` : ''}>${a11yTitle}</rect>`;
      } else {
        // Build a path with asymmetric rounded corners.
        const rr = r;
        const x2 = x + drawW;
        let d;
        if (isFirst) {
          d = `M ${x + rr} 0 H ${x2} V ${h} H ${x + rr} A ${rr} ${rr} 0 0 1 ${x} ${h - rr} V ${rr} A ${rr} ${rr} 0 0 1 ${x + rr} 0 Z`;
        } else { // isLast
          d = `M ${x} 0 H ${x2 - rr} A ${rr} ${rr} 0 0 1 ${x2} ${rr} V ${h - rr} A ${rr} ${rr} 0 0 1 ${x2 - rr} ${h} H ${x} Z`;
        }
        svg += `<path data-slice="${i % 10}"${tipAttrs} d="${d}">${a11yTitle}</path>`;
      }
    }

    // Legend data (same shape pie/donut use).
    this.#legendData = data.map((d, i) => ({
      label: labels[i],
      value: vals[i],
      pct: ((vals[i] / total) * 100).toFixed(1),
      slot: i % 10,
    }));

    return { svg, viewBox: `0 0 ${w} ${h}` };
  }

  /* ── Stacked bar ──────────────────────────────────────────────── */

  #renderStackedBar() {
    const data = this.#data;
    const keys = this.#yKeys();
    const labels = data.map(d => d[this.x] ?? '');

    /* Get stacked totals for max */
    const totals = data.map(d => keys.reduce((sum, k) => sum + (+(d[k] ?? 0)), 0));
    const ticks = niceScale(0, Math.max(...totals), 5);
    const maxVal = ticks[ticks.length - 1];

    const dims = this.#dims();
    const { width, height, pad } = dims;
    const plotH = height - pad.top - pad.bottom;
    const plotW = width - pad.left - pad.right;
    const barW = plotW / data.length;
    const barInner = barW * 0.6;
    const barGap = (barW - barInner) / 2;

    let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);

    for (let i = 0; i < data.length; i++) {
      let stackY = pad.top + plotH;
      const segCount = keys.length;
      for (let k = 0; k < segCount; k++) {
        if (this.#isSeriesHidden(keys[k])) continue;
        const v = +(data[i][keys[k]] ?? 0);
        const segH = maxVal ? (v / maxVal) * plotH : 0;
        if (segH <= 0) { stackY -= segH; continue; }
        stackY -= segH;
        const bx = pad.left + barW * i + barGap;
        const by = stackY;
        const bh = segH;
        const isTop = k === segCount - 1;
        const r = this.#resolveRadius();

        const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;

        if (isTop) {
          // Top segment (or single segment) — round top corners only.
          // Bottom edge sits on the next segment OR the axis baseline; either
          // way it should be flush, so we never round the bottom corners.
          svg += `<path${attrs} d="${topRoundedBarPath(bx, by, barInner, bh, r)}"/>`;
        } else {
          // Middle and bottom segments — flush on both ends. Bottom sits on
          // the axis baseline; middle sits between segments.
          svg += `<rect${attrs} x="${bx}" y="${by}" width="${barInner}" height="${bh}"/>`;
        }
      }
    }

    this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Grouped bar ──────────────────────────────────────────────── */

  #renderGroupedBar() {
    const data = this.#data;
    const keys = this.#yKeys();
    const labels = data.map(d => d[this.x] ?? '');

    const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
    const ticks = niceScale(0, Math.max(...allVals), 5);
    const maxVal = ticks[ticks.length - 1];

    const dims = this.#dims();
    const { width, height, pad } = dims;
    const plotH = height - pad.top - pad.bottom;
    const plotW = width - pad.left - pad.right;
    const groupW = plotW / data.length;
    const barGap = 3;
    const totalBarSpace = groupW * 0.7;
    const subBarW = (totalBarSpace - barGap * (keys.length - 1)) / keys.length;
    const groupPad = (groupW - totalBarSpace) / 2;

    let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);

    for (let i = 0; i < data.length; i++) {
      for (let k = 0; k < keys.length; k++) {
        if (this.#isSeriesHidden(keys[k])) continue;
        const v = +(data[i][keys[k]] ?? 0);
        const barH = maxVal ? (v / maxVal) * plotH : 0;
        const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
        const by = pad.top + plotH - barH;
        svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${topRoundedBarPath(bx, by, subBarW, barH, this.#resolveRadius())}"/>`;

        if (!this.hideValues) {
          svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
        }
      }
    }

    this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Multi-line ───────────────────────────────────────────────── */

  #renderMultiLine() {
    const data = this.#data;
    const keys = this.#yKeys();
    const labels = data.map(d => d[this.x] ?? '');

    const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
    const ticks = niceScale(0, Math.max(...allVals), 5);
    const maxVal = ticks[ticks.length - 1];

    const dims = this.#dims();
    const { width, height, pad } = dims;
    const plotH = height - pad.top - pad.bottom;
    const plotW = width - pad.left - pad.right;
    const step = plotW / Math.max(data.length - 1, 1);

    let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);

    for (let k = 0; k < keys.length; k++) {
      if (this.#isSeriesHidden(keys[k])) continue;
      const vals = data.map(d => +(d[keys[k]] ?? 0));
      const points = vals.map((v, i) => {
        const px = pad.left + step * i;
        const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
        return { x: px, y: py, v, label: labels[i] };
      });

      const baseline = pad.top + plotH;
      const t = Math.max(0, Math.min(1, this.smooth));

      /* Area fill */
      svg += `<path data-area${this.#seriesFill(k % 10, keys[k])} d="${smoothAreaPath(points, baseline, t)}"/>`;

      /* Line */
      svg += `<path data-line${this.#seriesStroke(k % 10, keys[k])} d="${smoothPath(points, t)}"/>`;

      /* Dots + hit targets. Hit circles deliberately omit data-slice so
         they aren't caught by the circle[data-slice] fill rule in CSS. */
      for (const p of points) {
        svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])} cx="${p.x}" cy="${p.y}" r="3"/>`;
        svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
      }
    }

    this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));

    return { svg, viewBox: `0 0 ${width} ${height}` };
  }

  /* ── Legend ────────────────────────────────────────────────────── */

  #legendData = null;

  /* Public getter — external consumers (chart-legend-ui[for]) read this to
     mirror series data. Returns a defensive shallow copy so callers can't
     mutate our internals. Null when the chart has no legend-bearing type
     or hasn't rendered yet. */
  get legendData() {
    return this.#legendData ? this.#legendData.map(it => ({ ...it })) : null;
  }

  #buildLegend() {
    if (!this.#legendData || !this.#legendData.length) return null;

    const legend = document.createElement('div');
    legend.setAttribute('data-legend', '');

    for (const item of this.#legendData) {
      const el = document.createElement('span');
      el.setAttribute('data-legend-item', '');
      if (item.key) el.setAttribute('data-series-key', item.key);

      const dot = document.createElement('span');
      dot.setAttribute('data-legend-dot', '');
      dot.setAttribute('data-slice', String(item.slot));
      if (item.key) dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
      el.appendChild(dot);

      const text = document.createElement('span');
      text.textContent = item.pct ? `${item.label} (${item.pct}%)` : item.label;
      el.appendChild(text);

      legend.appendChild(el);
    }

    /* Intentionally DO NOT null #legendData here — public `legendData`
       getter needs it to survive so chart-legend-ui[for] can mirror. */
    return legend;
  }
}
