// royalty-viz-charts.jsx — Royalty viz chart primitives
// ─────────────────────────────────────────────────────────────────
// All charts are inline SVG, no deps. Pure visual functions that
// take pre-aggregated data from RVE.* helpers.
//
// Components (all on window.RVCharts):
//   TimeSeries  — line/area with MoM/YoY overlays
//   StackedBar  — by-source stacked
//   Heatmap     — work × period grid
//   SparkGrid   — per-work mini trends
//   Pareto      — bars + cum curve
//   Treemap     — squarified
//   Sankey      — 3-tier flow
//   Choropleth  — territory map
//   Cohort      — release-age × earnings
//
// Conventions:
//   • Editorial palette pulled from --ink / --accent / a small ramp
//   • Click handlers cross-filter via RVE.toggleFilter(dim, value)
//   • Hover tooltip via simple title attribute (lo-fi but reliable)
// ─────────────────────────────────────────────────────────────────
(function () {
  if (typeof window === 'undefined' || !window.React) return;
  if (window.RVCharts) return;
  const h = React.createElement;

  // ─── FORMATTERS ──────────────────────────────────────────────
  const fmt$ = (n) => {
    if (n == null || isNaN(n)) return '—';
    if (Math.abs(n) >= 1e9) return '$' + (n / 1e9).toFixed(2) + 'B';
    if (Math.abs(n) >= 1e6) return '$' + (n / 1e6).toFixed(2) + 'M';
    if (Math.abs(n) >= 1e3) return '$' + (n / 1e3).toFixed(1) + 'K';
    return '$' + Math.round(n).toLocaleString();
  };
  const fmtPct = (p) => (p >= 0 ? '+' : '') + (p * 100).toFixed(1) + '%';

  // Editorial palette — newsprint-warm + a small categorical ramp
  const RAMP = ['#0e0e0c', '#a32a18', '#0a8754', '#1f4ed8', '#c97a00', '#7a2cb5', '#1f7a3a', '#b81d1d', '#3344ff', '#f0d028', '#33d97a', '#ff5b1f'];

  function colorFor(key, idx) {
    return RAMP[(idx ?? hash(String(key))) % RAMP.length];
  }
  function hash(s) {
    let n = 0;
    for (let i = 0; i < s.length; i++) n = (n * 31 + s.charCodeAt(i)) | 0;
    return Math.abs(n);
  }

  // ═══════════════════════════════════════════════════════════════
  // TIME SERIES
  // ═══════════════════════════════════════════════════════════════
  function TimeSeries({ points, w = 720, h: H = 240, color, mode, showYoY, onPointClick }) {
    if (!points || !points.length) return null;
    const padL = 56, padR = 14, padT = 18, padB = 30;
    const W = w - padL - padR, HH = H - padT - padB;
    const vs = points.map(p => p.value);
    const max = Math.max(...vs) * 1.1 || 1;
    const x = (i) => padL + (points.length === 1 ? W / 2 : (i / (points.length - 1)) * W);
    const y = (v) => padT + HH - (v / max) * HH;
    const line = points.map((p, i) => `${x(i)},${y(p.value)}`).join(' ');
    const area = `${x(0)},${padT + HH} ${line} ${x(points.length - 1)},${padT + HH}`;
    const ticks = 4;
    return h('svg', { width: w, height: H, style: { display: 'block', overflow: 'visible' } },
      // grid
      [...Array(ticks + 1)].map((_, i) => {
        const v = (i / ticks) * max;
        return h('g', { key: 'g' + i },
          h('line', { x1: padL, x2: w - padR, y1: y(v), y2: y(v), stroke: 'var(--rule-soft)', strokeWidth: 0.5 }),
          h('text', { x: padL - 6, y: y(v) + 3, textAnchor: 'end', className: 'ff-mono', style: { fontSize: 9, fill: 'var(--ink-3)' } }, fmt$(v)),
        );
      }),
      // x-axis
      points.map((p, i) => i % Math.max(1, Math.floor(points.length / 8)) === 0 ?
        h('text', { key: 'x' + i, x: x(i), y: padT + HH + 14, textAnchor: 'middle', className: 'ff-mono', style: { fontSize: 9, fill: 'var(--ink-3)' } }, p.key) : null),
      // area fill
      mode !== 'line' && h('polygon', { points: area, fill: color || 'var(--ink)', opacity: 0.08 }),
      // line
      h('polyline', { points: line, fill: 'none', stroke: color || 'var(--ink)', strokeWidth: 1.6 }),
      // YoY overlay (lighter)
      showYoY && points.map((p, i) => {
        if (p.yoy == null) return null;
        return h('g', { key: 'y' + i },
          h('circle', { cx: x(i), cy: y(p.value), r: 2.5, fill: p.yoy >= 0 ? '#0a8754' : '#a32a18' }),
        );
      }),
      // dots + click
      points.map((p, i) => h('circle', {
        key: 'p' + i, cx: x(i), cy: y(p.value), r: 4, fill: 'transparent',
        style: { cursor: onPointClick ? 'pointer' : 'default' },
        onClick: () => onPointClick && onPointClick(p),
      },
        h('title', null, `${p.key} · ${fmt$(p.value)}${p.yoy != null ? ` · YoY ${fmtPct(p.yoy)}` : ''}`)
      )),
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // STACKED BAR (by source, over time)
  // ═══════════════════════════════════════════════════════════════
  function StackedBar({ matrix, w = 720, h: H = 280, onCellClick }) {
    // matrix: { periods: [...], series: [{ key, values: [...] }] }
    if (!matrix?.periods?.length) return null;
    const { periods, series } = matrix;
    const padL = 56, padR = 14, padT = 14, padB = 30;
    const W = w - padL - padR, HH = H - padT - padB;
    // Compute column totals
    const totals = periods.map((_, i) => series.reduce((a, s) => a + (s.values[i] || 0), 0));
    const max = Math.max(...totals) * 1.05 || 1;
    const barW = (W / periods.length) * 0.78;
    const gap = (W / periods.length) * 0.22;
    const ticks = 4;
    return h('svg', { width: w, height: H, style: { display: 'block' } },
      [...Array(ticks + 1)].map((_, i) => {
        const v = (i / ticks) * max;
        return h('g', { key: 'g' + i },
          h('line', { x1: padL, x2: w - padR, y1: padT + HH - (v / max) * HH, y2: padT + HH - (v / max) * HH, stroke: 'var(--rule-soft)', strokeWidth: 0.5 }),
          h('text', { x: padL - 6, y: padT + HH - (v / max) * HH + 3, textAnchor: 'end', className: 'ff-mono', style: { fontSize: 9, fill: 'var(--ink-3)' } }, fmt$(v)),
        );
      }),
      periods.map((p, i) => {
        const x = padL + i * (barW + gap) + gap / 2;
        let ySoFar = padT + HH;
        return h('g', { key: 'col' + i },
          series.map((s, si) => {
            const v = s.values[i] || 0;
            if (v <= 0) return null;
            const segH = (v / max) * HH;
            ySoFar -= segH;
            return h('rect', {
              key: si, x, y: ySoFar, width: barW, height: segH,
              fill: colorFor(s.key, si),
              style: { cursor: onCellClick ? 'pointer' : 'default' },
              onClick: () => onCellClick && onCellClick({ period: p, source: s.key, value: v }),
            }, h('title', null, `${s.key} · ${p} · ${fmt$(v)}`));
          }),
          i % Math.max(1, Math.floor(periods.length / 8)) === 0 &&
          h('text', { x: x + barW / 2, y: padT + HH + 14, textAnchor: 'middle', className: 'ff-mono', style: { fontSize: 9, fill: 'var(--ink-3)' } }, p),
        );
      }),
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // HEATMAP
  // ═══════════════════════════════════════════════════════════════
  function Heatmap({ rowKeys, colKeys, cells, w = 720, cellH = 22, labelW = 180, onCellClick }) {
    if (!rowKeys?.length || !colKeys?.length) return null;
    const cellW = (w - labelW) / colKeys.length;
    const H = rowKeys.length * cellH + 28;
    const max = Math.max(...cells.map(c => c.value)) || 1;
    const cellMap = {};
    cells.forEach(c => { cellMap[c.row + '||' + c.col] = c.value; });
    return h('svg', { width: w, height: H, style: { display: 'block' } },
      // column headers
      colKeys.map((c, i) => i % Math.max(1, Math.floor(colKeys.length / 12)) === 0 &&
        h('text', { key: 'c' + i, x: labelW + i * cellW + cellW / 2, y: 16, textAnchor: 'middle', className: 'ff-mono', style: { fontSize: 9, fill: 'var(--ink-3)' } }, c)
      ),
      rowKeys.map((r, ri) => h('g', { key: 'r' + ri },
        h('text', { x: labelW - 8, y: 28 + ri * cellH + cellH / 2 + 3, textAnchor: 'end', style: { fontSize: 11, fill: 'var(--ink-2)' } }, String(r).slice(0, 22)),
        colKeys.map((c, ci) => {
          const v = cellMap[r + '||' + c] || 0;
          const opacity = v ? 0.1 + (v / max) * 0.9 : 0;
          return h('rect', {
            key: 'c' + ci,
            x: labelW + ci * cellW, y: 28 + ri * cellH, width: cellW - 1, height: cellH - 1,
            fill: 'var(--ink)', opacity,
            style: { cursor: onCellClick ? 'pointer' : 'default' },
            onClick: () => v > 0 && onCellClick && onCellClick({ row: r, col: c, value: v }),
          }, h('title', null, `${r} · ${c} · ${fmt$(v)}`));
        })
      ))
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // SPARK GRID — per-work mini trends
  // ═══════════════════════════════════════════════════════════════
  function SparkGrid({ items, columns = 4, w = 720, rowH = 50, onItemClick }) {
    if (!items?.length) return null;
    const cellW = w / columns;
    const rows = Math.ceil(items.length / columns);
    return h('div', { style: { display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 0, border: '1px solid var(--rule)' } },
      items.map((it, i) => {
        const max = Math.max(...it.points.map(p => p.value), 1);
        const sparkW = cellW - 24;
        const sparkH = rowH - 30;
        const pts = it.points.map((p, j) => `${(j / Math.max(1, it.points.length - 1)) * sparkW},${sparkH - (p.value / max) * sparkH}`).join(' ');
        return h('div', {
          key: it.key,
          onClick: () => onItemClick && onItemClick(it),
          style: { padding: '10px 12px', borderRight: i % columns < columns - 1 ? '1px solid var(--rule-soft)' : 0, borderBottom: i < items.length - columns ? '1px solid var(--rule-soft)' : 0, cursor: onItemClick ? 'pointer' : 'default' },
        },
          h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 } },
            h('span', { style: { fontSize: 11, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: cellW * 0.55 } }, it.key),
            h('span', { className: 'ff-mono', style: { fontSize: 10, color: 'var(--ink-3)' } }, fmt$(it.total)),
          ),
          h('svg', { width: sparkW, height: sparkH, style: { display: 'block' } },
            h('polyline', { points: pts, fill: 'none', stroke: 'var(--ink)', strokeWidth: 1.2 }),
          ),
        );
      })
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // PARETO
  // ═══════════════════════════════════════════════════════════════
  function Pareto({ rows, w = 720, h: H = 280, onBarClick }) {
    if (!rows?.length) return null;
    const padL = 56, padR = 56, padT = 14, padB = 50;
    const W = w - padL - padR, HH = H - padT - padB;
    const max = rows[0]?.value || 1;
    const barW = (W / rows.length) * 0.78;
    const gap = (W / rows.length) * 0.22;
    const xL = (i) => padL + i * (barW + gap) + gap / 2 + barW / 2;
    const yL = padT;
    const yR = padT + HH;
    // 80% line
    const eightyIdx = rows.findIndex(r => r.cumPct >= 0.8);
    return h('svg', { width: w, height: H, style: { display: 'block' } },
      // grid
      [0.25, 0.5, 0.75, 1].map(p => h('line', { key: p, x1: padL, x2: w - padR, y1: yR - HH * p, y2: yR - HH * p, stroke: 'var(--rule-soft)', strokeWidth: 0.5 })),
      // bars
      rows.map((r, i) => {
        const x = padL + i * (barW + gap) + gap / 2;
        const bh = (r.value / max) * HH;
        return h('g', { key: i },
          h('rect', { x, y: yR - bh, width: barW, height: bh, fill: 'var(--ink)', opacity: 0.85, style: { cursor: onBarClick ? 'pointer' : 'default' }, onClick: () => onBarClick && onBarClick(r) },
            h('title', null, `${r.key} · ${fmt$(r.value)} · ${(r.share * 100).toFixed(1)}%`)),
          i < 12 && h('text', { x: x + barW / 2, y: yR + 14, textAnchor: 'end', transform: `rotate(-30, ${x + barW / 2}, ${yR + 14})`, style: { fontSize: 9, fill: 'var(--ink-3)' } }, String(r.key).slice(0, 14)),
        );
      }),
      // cum curve
      h('polyline', {
        points: rows.map((r, i) => `${xL(i)},${yR - r.cumPct * HH}`).join(' '),
        fill: 'none', stroke: '#a32a18', strokeWidth: 1.4,
      }),
      // 80% reference
      eightyIdx >= 0 && h('line', { x1: padL, x2: w - padR, y1: yR - 0.8 * HH, y2: yR - 0.8 * HH, stroke: '#a32a18', strokeDasharray: '3 3', strokeWidth: 0.7 }),
      eightyIdx >= 0 && h('text', { x: w - padR - 4, y: yR - 0.8 * HH - 4, textAnchor: 'end', className: 'ff-mono', style: { fontSize: 9, fill: '#a32a18' } }, '80%'),
      // right axis (% cumulative)
      [0.25, 0.5, 0.75, 1].map(p => h('text', { key: p, x: w - padR + 6, y: yR - HH * p + 3, className: 'ff-mono', style: { fontSize: 9, fill: 'var(--ink-3)' } }, (p * 100) + '%')),
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // TREEMAP — squarified
  // ═══════════════════════════════════════════════════════════════
  function Treemap({ rows, w = 720, h: H = 420, onCellClick }) {
    if (!rows?.length) return null;
    const total = rows.reduce((a, r) => a + r.value, 0) || 1;
    // Squarified treemap algorithm (Bruls et al.)
    const sorted = [...rows].sort((a, b) => b.value - a.value);
    const cells = squarify(sorted.map(r => ({ ...r, area: (r.value / total) * w * H })), { x: 0, y: 0, w, h: H });
    return h('svg', { width: w, height: H, style: { display: 'block' } },
      cells.map((c, i) => h('g', { key: i, onClick: () => onCellClick && onCellClick(c.row), style: { cursor: onCellClick ? 'pointer' : 'default' } },
        h('rect', { x: c.x, y: c.y, width: c.w, height: c.h, fill: 'var(--ink)', opacity: 0.05 + (c.row.share || 0) * 0.85, stroke: 'var(--bg)', strokeWidth: 1 },
          h('title', null, `${c.row.key} · ${fmt$(c.row.value)} · ${(c.row.share * 100).toFixed(1)}%`)),
        c.w > 50 && c.h > 24 && h('text', { x: c.x + 6, y: c.y + 14, style: { fontSize: 10, fill: c.row.share > 0.3 ? 'var(--bg)' : 'var(--ink-2)', fontWeight: 500 } }, String(c.row.key).slice(0, Math.floor(c.w / 7))),
        c.w > 50 && c.h > 38 && h('text', { x: c.x + 6, y: c.y + 28, className: 'ff-mono', style: { fontSize: 9, fill: c.row.share > 0.3 ? 'var(--bg)' : 'var(--ink-3)' } }, fmt$(c.row.value)),
      )),
    );
  }

  function squarify(items, rect) {
    const out = [];
    if (!items.length) return out;
    const total = items.reduce((a, r) => a + r.area, 0);
    if (total <= 0 || rect.w <= 0 || rect.h <= 0) return out;
    // Slice & dice fallback: pick orientation by aspect ratio, lay items in rows
    const horizontal = rect.w >= rect.h;
    let pos = 0;
    items.forEach(it => {
      const frac = it.area / total;
      if (horizontal) {
        const w = rect.w * frac;
        out.push({ x: rect.x + pos, y: rect.y, w, h: rect.h, row: it });
        pos += w;
      } else {
        const hh = rect.h * frac;
        out.push({ x: rect.x, y: rect.y + pos, w: rect.w, h: hh, row: it });
        pos += hh;
      }
    });
    return out;
  }

  // ═══════════════════════════════════════════════════════════════
  // SANKEY
  // ═══════════════════════════════════════════════════════════════
  function Sankey({ nodes, links, w = 720, h: H = 420, onNodeClick }) {
    if (!nodes?.length) return null;
    const layers = {};
    nodes.forEach(n => { (layers[n.layer] = layers[n.layer] || []).push(n); });
    const layerCount = Object.keys(layers).length;
    const colW = w / layerCount;
    const nodeW = 12;
    const layout = {};
    Object.entries(layers).forEach(([li, ns]) => {
      const sorted = [...ns].sort((a, b) => b.value - a.value);
      const total = sorted.reduce((a, n) => a + n.value, 0) || 1;
      let yPos = 10;
      sorted.forEach(n => {
        const hh = Math.max(2, (n.value / total) * (H - 20));
        layout[n.id] = { x: +li * colW + (colW - nodeW) / 2, y: yPos, w: nodeW, h: hh, name: n.name, layer: n.layer };
        yPos += hh + 2;
      });
    });
    // Per-side cursors for links
    const srcCursor = {}, tgtCursor = {};
    return h('svg', { width: w, height: H, style: { display: 'block' } },
      // links
      links.map((l, i) => {
        const a = layout[l.source], b = layout[l.target];
        if (!a || !b) return null;
        const total = nodes.find(n => n.id === l.source)?.value || 1;
        const lh = Math.max(0.5, (l.value / total) * a.h);
        const sy = (srcCursor[l.source] = (srcCursor[l.source] || a.y) + 0);
        srcCursor[l.source] = sy + lh;
        const ty = (tgtCursor[l.target] = (tgtCursor[l.target] || b.y) + 0);
        tgtCursor[l.target] = ty + lh;
        const x1 = a.x + a.w, x2 = b.x;
        const cx = (x1 + x2) / 2;
        const path = `M${x1},${sy + lh / 2} C${cx},${sy + lh / 2} ${cx},${ty + lh / 2} ${x2},${ty + lh / 2}`;
        return h('path', { key: i, d: path, fill: 'none', stroke: 'var(--ink)', opacity: 0.18, strokeWidth: lh },
          h('title', null, `${a.name} → ${b.name} · ${fmt$(l.value)}`));
      }),
      // nodes
      Object.entries(layout).map(([id, l]) => h('g', { key: id, onClick: () => onNodeClick && onNodeClick({ id, ...l }), style: { cursor: onNodeClick ? 'pointer' : 'default' } },
        h('rect', { x: l.x, y: l.y, width: l.w, height: l.h, fill: 'var(--ink)' },
          h('title', null, `${l.name}`)),
        l.h > 8 && h('text', { x: l.layer === 0 ? l.x + l.w + 4 : l.x - 4, y: l.y + l.h / 2 + 3, textAnchor: l.layer === 0 ? 'start' : 'end', style: { fontSize: 10, fill: 'var(--ink-2)' } }, String(l.name).slice(0, 18)),
      )),
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // CHOROPLETH — territory map (simple lat-lon dot map)
  // ═══════════════════════════════════════════════════════════════
  // ISO2 → approx [lon, lat] of a representative city
  const TERRITORY_GEO = {
    US: [-98, 38], CA: [-95, 55], MX: [-102, 23], BR: [-55, -10], AR: [-65, -34],
    GB: [-2, 53], IE: [-8, 53], FR: [2, 47], DE: [10, 51], ES: [-3, 40], PT: [-8, 40],
    IT: [12, 42], NL: [5, 52], BE: [4, 50], CH: [8, 47], AT: [14, 48], SE: [15, 60],
    NO: [10, 60], DK: [10, 56], FI: [25, 64], PL: [19, 52], CZ: [15, 50], RU: [60, 60],
    TR: [35, 39], GR: [22, 39], JP: [138, 36], KR: [127, 36], CN: [104, 35], IN: [78, 22],
    AU: [134, -25], NZ: [172, -41], ZA: [25, -29], NG: [8, 9], EG: [30, 26], AE: [54, 24],
    SA: [45, 25], ID: [113, -2], PH: [121, 12], TH: [100, 15], VN: [108, 16], MY: [102, 4],
    SG: [103, 1], HK: [114, 22], TW: [121, 23], CO: [-74, 4], CL: [-71, -32], PE: [-76, -10],
    XW: [0, 0],
  };
  function Choropleth({ rows, w = 720, h: H = 360, onTerritoryClick }) {
    if (!rows?.length) return null;
    const max = Math.max(...rows.map(r => r.value), 1);
    function project([lon, lat]) {
      // Equirectangular
      const x = ((lon + 180) / 360) * w;
      const y = ((90 - lat) / 180) * H;
      return [x, y];
    }
    return h('svg', { width: w, height: H, style: { display: 'block', background: 'var(--bg-2)' } },
      // graticule (simple)
      [-60, -30, 0, 30, 60].map(lat => {
        const [, y] = project([0, lat]);
        return h('line', { key: 'lat' + lat, x1: 0, x2: w, y1: y, y2: y, stroke: 'var(--rule-soft)', strokeWidth: 0.5 });
      }),
      [-120, -60, 0, 60, 120].map(lon => {
        const [x] = project([lon, 0]);
        return h('line', { key: 'lon' + lon, x1: x, x2: x, y1: 0, y2: H, stroke: 'var(--rule-soft)', strokeWidth: 0.5 });
      }),
      // dots
      rows.map((r, i) => {
        const geo = TERRITORY_GEO[r.key];
        if (!geo) return null;
        const [x, y] = project(geo);
        const radius = Math.sqrt((r.value / max)) * 18 + 2;
        return h('g', { key: i, onClick: () => onTerritoryClick && onTerritoryClick(r), style: { cursor: onTerritoryClick ? 'pointer' : 'default' } },
          h('circle', { cx: x, cy: y, r: radius, fill: '#a32a18', opacity: 0.55, stroke: '#a32a18', strokeWidth: 0.8 },
            h('title', null, `${r.key} · ${fmt$(r.value)} · ${(r.share * 100).toFixed(1)}%`)),
          radius > 8 && h('text', { x, y: y + 3, textAnchor: 'middle', className: 'ff-mono', style: { fontSize: 9, fill: 'var(--bg)', fontWeight: 600 } }, r.key),
        );
      }),
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // COHORT — release-age × earnings
  // ═══════════════════════════════════════════════════════════════
  function Cohort({ cohorts, w = 720, rowH = 24, maxAge = 18, onCellClick }) {
    if (!cohorts?.length) return null;
    const visible = cohorts.slice(0, 12);
    const labelW = 200;
    const cellW = (w - labelW) / (maxAge + 1);
    const H = visible.length * rowH + 32;
    const allValues = visible.flatMap(c => c.cells.map(x => x.value));
    const max = Math.max(...allValues, 1);
    return h('svg', { width: w, height: H, style: { display: 'block' } },
      // header
      [...Array(maxAge + 1)].map((_, i) => h('text', { key: i, x: labelW + i * cellW + cellW / 2, y: 18, textAnchor: 'middle', className: 'ff-mono', style: { fontSize: 9, fill: 'var(--ink-3)' } }, 'M' + i)),
      visible.map((c, ri) => h('g', { key: ri },
        h('text', { x: labelW - 8, y: 32 + ri * rowH + rowH / 2 + 3, textAnchor: 'end', style: { fontSize: 11, fill: 'var(--ink-2)' } }, String(c.release).slice(0, 24)),
        c.cells.map((cell, ci) => {
          if (cell.ageMonths < 0 || cell.ageMonths > maxAge) return null;
          const opacity = 0.1 + (cell.value / max) * 0.9;
          return h('rect', {
            key: ci,
            x: labelW + cell.ageMonths * cellW, y: 32 + ri * rowH,
            width: cellW - 1, height: rowH - 1,
            fill: 'var(--ink)', opacity,
            style: { cursor: onCellClick ? 'pointer' : 'default' },
            onClick: () => onCellClick && onCellClick({ release: c.release, ageMonths: cell.ageMonths, value: cell.value }),
          }, h('title', null, `${c.release} · M${cell.ageMonths} · ${fmt$(cell.value)}`));
        })
      )),
    );
  }

  // ─── PUBLIC ──────────────────────────────────────────────────
  window.RVCharts = { TimeSeries, StackedBar, Heatmap, SparkGrid, Pareto, Treemap, Sankey, Choropleth, Cohort, fmt$, fmtPct, colorFor, RAMP };
})();
