Theme
On this page (4)
Component Skill — components/chart/component-skill.md

Native basis

A plain <div class="chart"> mount rendered by ECharts (SVG renderer), with role="img" + aria-label as the accessible surface and ECharts' own aria.enabled output on top.

Web Platform APIs

ResizeObserverprefers-reduced-motiongetComputedStylerole="img"

Attributes

data-chartdata-sizedata-init

Sizes (data-size)

(none)20rem - default reading sizesm14rem - compact cards, dashboardslg28rem - hero visualizations

§Declarative

data-chart is the ECharts option; the token-derived THEME supplies every default it leaves out - palette, type scale, axis and grid chrome, tooltip. Toggle dark mode or switch the theme: the chart re-themes live. The vendor script loads once; chart.js mounts every .chart element automatically.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - the rendered surface; setState('default', { option }) replaces the option wholesale (notMerge), a bare call is a no-op

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name).

§CSS view file

The sizing surface (three data-size height presets) and the editorial .chart-frame chrome - tokens only.

/* -- Chart component -----------------------------------------------------
   The sizing surface + editorial frame for the ECharts mount (chart.ts owns
   the runtime: theme adapter, resize, story). The mount itself is a plain
   block with three height presets via data-size; .chart-frame is the
   card-like bordered wrapper (title/dek/source chrome) for editorial
   data-storytelling. Tokens only (AGENTS.md token boundary rule). */
@layer components {
  .chart {
    width: 100%;
    height: 20rem;
    &[data-size='sm'] {
      height: 14rem;
    }
    &[data-size='lg'] {
      height: 28rem;
    }
    /* ECharts animates internally (canvas/svg), but the mount itself never
       transitions - reduced motion is handled in the JS base option */
    @media (prefers-reduced-motion: reduce) {
      & * {
        transition: none !important;
      }
    }
  }
  /* the editorial frame: chart + headline + dek + source line, card-like */
  .chart-frame {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    padding: 1.5rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--card);
    color: var(--card-foreground);
  }
  .chart-title {
    margin: 0;
    font-size: 1rem;
    font-weight: 600;
    line-height: 1.25;
    letter-spacing: var(--tracking-normal);
    text-wrap: balance;
  }
  .chart-dek {
    margin: 0;
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--muted-foreground);
    text-wrap: pretty;
  }
  .chart-source {
    margin: 0;
    font-size: 0.75rem;
    line-height: 1.4;
    color: var(--muted-foreground);
  }
  @media (prefers-contrast: more) {
    .chart-frame {
      border-width: 2px;
    }
    .chart-dek,
    .chart-source {
      color: var(--foreground);
    }
  }
  /* Windows High Contrast Mode: frame + chrome fall back to system colors */
  @media (forced-colors: active) {
    .chart-frame {
      border-color: CanvasText;
      background: Canvas;
      color: CanvasText;
    }
    .chart-title,
    .chart-dek,
    .chart-source {
      color: CanvasText;
    }
  }
}

§JavaScript view file

The full runtime: vendor-load guard, token theme adapter, deep-merge mount with SVG renderer + ResizeObserver, chartStory, and the State API wiring.

// -- Chart ---------------------------------------------------------------
// A thin runtime primitive around Apache ECharts (vendor script, loaded by
// the page - zero echarts bytes ship here). JS is the thin part: declarative
// mounting (data-chart JSON), a theme adapter that turns our design tokens
// (getComputedStyle) into an ECharts THEME object, ResizeObserver-driven
// resize, live re-theming (dark mode / preset swaps), reduced-motion
// suppression, a story driver for option transitions and the deck stage
// (one morphing chart across presentation slides). Everything visual is
// chart.css + the token file (AGENTS.md "Native web platform first").
//
// Markup contract: `.chart` mount carrying data-chart='{…}' (the ECharts
// option; the token theme supplies every default it leaves out) + role="img"
// + aria-label. State lives ON THE ELEMENT (dataset.stateName) - the bound
// `api` is the only state mutator (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
const chartStates = ['default'];
/** The ONE vendor-load error text - same actionable message everywhere. */
const ECHARTS_NOT_LOADED =
  'chart: echarts is not loaded - add <script src="https://cdn.jsdelivr.net/npm/echarts@6.1.0/dist/echarts.min.js"></script> before chart.js';
type Option = Record<string, unknown>;
/** Minimal structural view of the vendor global (read via globalThis, never window). */
interface EChartsInstanceLike {
  setOption(option: Option, notMerge?: boolean): void;
  setTheme?(theme: Option): void;
  resize(): void;
  clear?(): void;
  dispose(): void;
  isDisposed?(): boolean;
  getOption(): Option;
}
interface EChartsLike {
  init(el: HTMLElement, theme?: unknown, opts?: { renderer?: string }): EChartsInstanceLike;
}
/** Handle returned by df$.chart.mount() - the imperative lifecycle surface. */
export interface ChartMount {
  instance: EChartsInstanceLike;
  setOption(option: Option, notMerge?: boolean): void;
  dispose(): void;
}
/** The vendor runtime or the one actionable load-order error (query.ts style). */
function echartsRuntime(): EChartsLike {
  const echarts = (globalThis as { echarts?: EChartsLike }).echarts;
  if (!echarts) throw new Error(ECHARTS_NOT_LOADED);
  return echarts;
}
const isPlain = (v: unknown): v is Option => typeof v === 'object' && v !== null && !Array.isArray(v);
/** Recursive merge: plain objects combine, arrays/scalars are replaced. */
function deepMerge(base: Option, over: Option): Option {
  const out: Option = { ...base };
  for (const [key, value] of Object.entries(over)) {
    out[key] = isPlain(value) && isPlain(out[key]) ? deepMerge(out[key] as Option, value) : value;
  }
  return out;
}
const reducedMotion = (): boolean => matchMedia('(prefers-reduced-motion: reduce)').matches;
// -- color resolution ------------------------------------------------------
/** One 1×1 canvas resolves ANY CSS color the browser understands. */
let probe: CanvasRenderingContext2D | null = null;
/**
 * Why: tokens are oklch() (and themes may use color-mix/lab/…); ECharts
 * parses only hex/rgb/hsl - it would pass oklch through to SVG fills but
 * silently break every color interpolation (hover emphasis, visualMap
 * gradients, the morph between states). Painting one pixel and reading it
 * back is the browser's own conversion to sRGB. Returns [r, g, b, a] or
 * null for empty/invalid input.
 */
function rgba(css: string): [number, number, number, number] | null {
  if (!css || css === 'none') return null;
  probe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
  if (!probe) return null;
  probe.clearRect(0, 0, 1, 1);
  probe.fillStyle = 'rgba(1, 2, 3, 0.5)'; // sentinel: an invalid color keeps it
  probe.fillStyle = css;
  if (probe.fillStyle === 'rgba(1, 2, 3, 0.5)') return null;
  probe.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
  return [r, g, b, a / 255];
}
/** CSS color → 'rgb()/rgba()' ECharts can interpolate ('' when unresolvable). */
function toRgb(css: string, alpha = 1): string {
  const c = rgba(css);
  if (!c) return '';
  const a = +(c[3] * alpha).toFixed(3);
  return a >= 1 ? `rgb(${c[0]}, ${c[1]}, ${c[2]})` : `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`;
}
/**
 * Why: page and deck options that pick token colors themselves (a highlight
 * bar, a visualMap gradient) need ECharts-parseable colors too. Resolves a
 * token name ('--chart-2', read off the element) or any CSS color to
 * rgb()/rgba(), optionally at an alpha. '' when unresolvable.
 */
export function chartColor(el: HTMLElement, value: string, alpha = 1): string {
  const css = value.startsWith('--') ? getComputedStyle(el).getPropertyValue(value).trim() : value;
  return toRgb(css, alpha);
}
/** The first opaque background up the tree - the surface the chart sits on
 * (card, slide, page); pie/sunburst separators are drawn in it. */
function surfaceOf(el: HTMLElement): string {
  for (let node: HTMLElement | null = el; node; node = node.parentElement) {
    const c = rgba(getComputedStyle(node).backgroundColor);
    if (c && c[3] > 0.5) return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
  }
  return toRgb(getComputedStyle(document.documentElement).getPropertyValue('--background').trim()) || '#ffffff';
}
// -- the theme adapter ------------------------------------------------------
/**
 * Why: the theme adapter - the chart reads the DESIGN TOKENS off its own
 * computed style and returns an ECharts THEME object (passed to init() and
 * setTheme()). A theme, unlike a merged base option, is only defaults: it
 * survives setOption(…, notMerge) (stories, deck stages), applies per
 * component type (categoryAxis/valueAxis only style axes that EXIST - no
 * phantom axes on pies/treemaps) and per series type (bar radius, line
 * width, pie separators).
 *
 * Sources: the --chart-1..5 palette (resolved to rgb; empty tokens dropped),
 * the element's own `color` for text (so a chart inherits card, slide or
 * page foreground; muted/axis/grid are fixed mixes of it), --popover* for
 * the tooltip, --font-sans for type, and `--chart-font-size` (component-
 * local, default 13px; decks raise it to artboard scale) for the type scale
 * every size here derives from. prefers-reduced-motion disables animation.
 */
export function chartTheme(el: HTMLElement): Option {
  const cs = getComputedStyle(el);
  const tok = (name: string): string => cs.getPropertyValue(name).trim();
  const fs = parseFloat(tok('--chart-font-size')) || 13;
  const k = fs / 13; // every length scales with the type
  const px = (n: number): number => Math.round(n * k * 10) / 10;
  const fg = toRgb(cs.color) || toRgb(tok('--foreground')) || '#111111';
  const muted = toRgb(cs.color, 0.62) || fg;
  const axis = toRgb(cs.color, 0.28) || fg;
  const grid = toRgb(cs.color, 0.1) || fg;
  const surface = surfaceOf(el);
  const palette = [1, 2, 3, 4, 5].map((n) => toRgb(tok(`--chart-${n}`))).filter(Boolean);
  // the chart speaks its container's typeface (a deck's display face, a
  // card's body face) - --font-sans only when nothing resolves
  const font = cs.fontFamily || tok('--font-sans') || 'system-ui, sans-serif';
  const label = { color: muted, fontSize: fs, fontFamily: font };
  const reduce = reducedMotion();
  const axisBase = {
    nameTextStyle: { ...label },
    nameGap: px(14),
    axisLabel: { ...label, margin: px(10) },
    axisTick: { show: false },
    splitArea: { show: false },
  };
  return {
    ...(palette.length > 0 ? { color: palette } : {}),
    backgroundColor: 'transparent',
    aria: { enabled: true },
    animation: !reduce,
    animationDuration: reduce ? 0 : 750,
    animationEasing: 'cubicOut',
    animationDurationUpdate: reduce ? 0 : 900,
    animationEasingUpdate: 'cubicInOut',
    textStyle: { fontFamily: font, color: fg, fontSize: fs },
    title: {
      textStyle: { color: fg, fontSize: px(16), fontWeight: 600, fontFamily: font },
      subtextStyle: { ...label },
    },
    // tight defaults (ECharts' own are 15%/10% gutters): outerBounds keeps
    // axis labels inside the box, the top leaves room for a legend row
    grid: { left: px(8), right: px(20), top: px(40), bottom: px(14) },
    legend: {
      top: 0,
      icon: 'roundRect',
      itemWidth: px(12),
      itemHeight: px(12),
      itemGap: px(18),
      textStyle: { color: muted, fontSize: fs, fontFamily: font },
      inactiveColor: grid,
      pageTextStyle: { color: muted },
    },
    tooltip: {
      ...(toRgb(tok('--popover')) ? { backgroundColor: toRgb(tok('--popover')) } : {}),
      borderColor: toRgb(tok('--border')) || axis,
      borderWidth: 1,
      padding: [px(8), px(12)],
      textStyle: { color: toRgb(tok('--popover-foreground')) || fg, fontSize: fs, fontFamily: font },
      extraCssText: 'border-radius: var(--radius-md, 8px); box-shadow: var(--shadow-md, 0 6px 16px rgba(0,0,0,.12));',
      axisPointer: {
        lineStyle: { color: axis, width: 1 },
        crossStyle: { color: axis },
        shadowStyle: { color: toRgb(cs.color, 0.05) },
        label: { backgroundColor: fg, color: surface, fontSize: fs },
      },
    },
    categoryAxis: {
      ...axisBase,
      axisLine: { show: true, lineStyle: { color: axis, width: 1 } },
      splitLine: { show: false },
    },
    valueAxis: {
      ...axisBase,
      axisLine: { show: false },
      splitLine: { show: true, lineStyle: { color: grid, width: 1 } },
    },
    logAxis: {
      ...axisBase,
      axisLine: { show: false },
      splitLine: { show: true, lineStyle: { color: grid, width: 1 } },
    },
    timeAxis: {
      ...axisBase,
      axisLine: { show: true, lineStyle: { color: axis, width: 1 } },
      splitLine: { show: false },
    },
    bar: {
      barMaxWidth: px(56),
      itemStyle: { borderRadius: px(4) },
      label: { color: fg, fontSize: fs, fontFamily: font },
    },
    line: {
      symbol: 'circle',
      symbolSize: px(7),
      lineStyle: { width: px(2.5), cap: 'round', join: 'round' },
      label: { color: fg, fontSize: fs, fontFamily: font, textBorderWidth: 0 },
      // ECharts outlines end labels in the series color by default - a halo
      // that smears on dark surfaces; plain foreground text reads cleaner
      endLabel: { color: fg, fontSize: fs, fontFamily: font, textBorderWidth: 0 },
    },
    scatter: { symbolSize: px(12), label: { color: fg, fontSize: fs, fontFamily: font } },
    pie: {
      itemStyle: { borderColor: surface, borderWidth: px(2), borderRadius: px(4) },
      label: { color: fg, fontSize: fs, fontFamily: font },
      labelLine: { lineStyle: { color: axis } },
    },
    sunburst: { itemStyle: { borderColor: surface, borderWidth: px(1.5) }, label: { fontSize: fs } },
    treemap: {
      itemStyle: { borderColor: surface, borderWidth: px(2), gapWidth: px(2) },
      label: { fontSize: fs },
      breadcrumb: { show: false },
    },
    sankey: { label: { color: fg, fontSize: fs }, lineStyle: { opacity: 0.35 } },
    radar: { axisName: { color: muted, fontSize: fs } },
    visualMap: { textStyle: { color: muted, fontSize: fs, fontFamily: font } },
  };
}
// -- live instances -----------------------------------------------------------
/** Live instances + their resize observers, keyed by element (never module state). */
const instances = new WeakMap<HTMLElement, EChartsInstanceLike>();
const observers = new WeakMap<HTMLElement, ResizeObserver>();
/** The mounted elements, iterable for re-theming (pruned when disconnected). */
const live = new Set<HTMLElement>();
/** Reduced motion is re-evaluated per apply: animation off wins over the option. */
function withMotion(option: Option): Option {
  return reducedMotion() ? { ...option, animation: false } : option;
}
/** Strings the canvas probe cannot read directly: token references and
 * CSS Color 4/5 functions ECharts cannot parse. */
const CSS_COLOR = /var\(--|^\s*(?:oklch|oklab|lch|lab|hwb|color-mix|color)\(/;
/**
 * Why: options may name design tokens - color: 'var(--primary)', a visualMap
 * range of ['var(--card)', 'var(--chart-2)'], even color-mix() over them —
 * so they stay declarative (data-chart JSON included) AND theme-proof. Every
 * such string is resolved against the element to rgb() at apply time; the
 * raw option is kept, so a theme change re-resolves it (see retheme()).
 */
function resolveColors(el: HTMLElement, value: unknown, cs?: CSSStyleDeclaration): unknown {
  if (typeof value === 'string') {
    if (!CSS_COLOR.test(value)) return value;
    const style = cs ?? getComputedStyle(el);
    const css = value.replace(/var\((--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (_m, name: string, fallback?: string) =>
      style.getPropertyValue(name).trim() || (fallback ?? '').trim(),
    );
    return toRgb(css) || value;
  }
  if (Array.isArray(value)) {
    const style = cs ?? getComputedStyle(el);
    return value.map((v) => resolveColors(el, v, style));
  }
  if (isPlain(value)) {
    const style = cs ?? getComputedStyle(el);
    const out: Option = {};
    for (const [k, v] of Object.entries(value)) out[k] = resolveColors(el, v, style);
    return out;
  }
  return value;
}
/** The raw ops since the last full (notMerge) apply, per element - replayed
 * after a theme change so token references re-resolve. A long-running merge
 * stream (a bar race ticking setOption) stops recording past the cap; such a
 * chart still re-themes its chrome, just not its per-series token colors. */
interface Journal { ops: [Option, unknown, unknown][]; overflow: boolean }
const journals = new WeakMap<HTMLElement, Journal>();
const JOURNAL_CAP = 64;
/** setOption's second argument is either a boolean or an opts object. */
const isNotMerge = (arg: unknown): boolean => arg === true || (isPlain(arg) && arg.notMerge === true);
/**
 * Why: ONE apply path for every caller - mount(), the returned handle, the
 * State API, stories, the deck stage AND authors holding the raw instance
 * (instance(el).setOption is wrapped): token references resolve, reduced
 * motion applies, and the op is journaled for re-theming.
 */
function wrapSetOption(el: HTMLElement, inst: EChartsInstanceLike): void {
  const raw = inst.setOption.bind(inst) as (o: Option, a?: unknown, b?: unknown) => void;
  const journal: Journal = { ops: [], overflow: false };
  journals.set(el, journal);
  (inst as { _rawSetOption?: typeof raw })._rawSetOption = raw;
  inst.setOption = ((option: Option, arg?: unknown, lazy?: unknown) => {
    if (isNotMerge(arg)) {
      journal.ops = [];
      journal.overflow = false;
    }
    if (journal.ops.length < JOURNAL_CAP) journal.ops.push([option, arg, lazy]);
    else journal.overflow = true;
    raw(withMotion(resolveColors(el, option) as Option), arg, lazy);
  }) as EChartsInstanceLike['setOption'];
}
/**
 * Why: the one mount path (declarative and imperative converge here). The
 * token theme goes to init(); the option carries only what the author said.
 * The renderer is SVG (crisp at any density, selectable, small); a
 * ResizeObserver keeps the canvas honest - never a window resize listener.
 */
export function mount(el: HTMLElement, option: Option = {}): ChartMount {
  const echarts = echartsRuntime();
  observers.get(el)?.disconnect();
  instances.get(el)?.dispose();
  const instance = echarts.init(el, chartTheme(el), { renderer: 'svg' });
  wrapSetOption(el, instance);
  instances.set(el, instance);
  live.add(el);
  watchTheme();
  instance.setOption(option, true);
  // resize on the next frame, not inside the observer: a synchronous resize
  // changes layout mid-delivery and the browser reports "ResizeObserver loop
  // completed with undelivered notifications" (a view switch, a sidebar
  // collapsing beside the chart)
  let frame = 0;
  const ro = new ResizeObserver(() => {
    cancelAnimationFrame(frame);
    frame = requestAnimationFrame(() => { if (!instance.isDisposed?.()) instance.resize(); });
  });
  ro.observe(el);
  observers.set(el, ro);
  replayOnSlide(el);
  replayOnView(el);
  return {
    instance,
    setOption: (opt, notMerge = false) => instance.setOption(opt, notMerge),
    dispose: () => {
      viewObserver?.unobserve(el);
      ro.disconnect();
      observers.delete(el);
      instances.delete(el);
      live.delete(el);
      instance.dispose();
    },
  };
}
/** The stored instance for an element (undefined until mounted). */
export function instance(el: HTMLElement): EChartsInstanceLike | undefined {
  return instances.get(el);
}
/** Re-apply the journal from scratch (clear first): the chart's entrance
 * animation plays again. */
function replay(el: HTMLElement, inst: EChartsInstanceLike): void {
  const journal = journals.get(el);
  const raw = (inst as { _rawSetOption?: (o: Option, a?: unknown, b?: unknown) => void })._rawSetOption;
  if (!journal || !raw || journal.overflow || journal.ops.length === 0) return;
  // ECharts' clear() is itself setOption({ series: [] }, true) - through the
  // wrapper it would RESET the journal; keep the ops and restore them after
  const ops = journal.ops.slice();
  inst.clear?.();
  journal.ops = ops;
  journal.overflow = false;
  for (const [option, arg, lazy] of ops) raw(withMotion(resolveColors(el, option) as Option), arg, lazy);
}
/** One shared observer for every chart still waiting to be seen. */
let viewObserver: IntersectionObserver | undefined;
/**
 * Why: a chart mounts as soon as its script runs - during page load, or far
 * below the fold - so its entrance animation (a gauge sweeping 0 → 77%, bars
 * growing) plays while nobody is looking. The first time a chart is actually
 * on screen (30% visible), its journal replays once, so the entrance plays
 * in view. Slide charts are excluded (replayOnSlide owns them), and so is
 * reduced motion (there is no animation to show).
 */
function replayOnView(el: HTMLElement): void {
  if (reducedMotion() || el.closest('[data-slide]') || typeof IntersectionObserver !== 'function') return;
  viewObserver ??= new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        const chartEl = entry.target as HTMLElement;
        viewObserver?.unobserve(chartEl);
        const i = instances.get(chartEl);
        if (i && chartEl.isConnected && !i.isDisposed?.()) replay(chartEl, i);
      }
    },
    { threshold: 0.3 },
  );
  viewObserver.observe(el);
}
/** Slides whose charts replay on activation (one observer per slide). */
const slideCharts = new WeakMap<HTMLElement, Set<HTMLElement>>();
/**
 * Why: a chart inside a presentation slide mounts while the slide is still
 * hidden (slides keep their full artboard size), so its entrance animation
 * would play unseen. Every activation of its slide ([data-active] appears)
 * replays the chart's entrance instead - each chart animates whenever its
 * slide comes on stage. The deck stage (.presentation-stage) morphs between
 * states instead and is excluded.
 */
function replayOnSlide(el: HTMLElement): void {
  if (el.classList.contains('presentation-stage')) return;
  const slide = el.closest<HTMLElement>('[data-slide]');
  if (!slide) return;
  let charts = slideCharts.get(slide);
  if (!charts) {
    charts = new Set();
    slideCharts.set(slide, charts);
    const set = charts;
    let wasActive = slide.hasAttribute('data-active');
    new MutationObserver(() => {
      const active = slide.hasAttribute('data-active');
      if (active && !wasActive) {
        for (const chartEl of set) {
          const i = instances.get(chartEl);
          if (i && chartEl.isConnected) replay(chartEl, i);
        }
      }
      wasActive = active;
    }).observe(slide, { attributes: true, attributeFilter: ['data-active'] });
  }
  charts.add(el);
}
/** Re-derive one chart's theme, then replay its journal so token
 * references in the option resolve against the new tokens too. */
function retheme(el: HTMLElement, inst: EChartsInstanceLike): void {
  inst.setTheme?.(chartTheme(el));
  const journal = journals.get(el);
  const raw = (inst as { _rawSetOption?: (o: Option, a?: unknown, b?: unknown) => void })._rawSetOption;
  if (!journal || !raw || journal.overflow) return;
  for (const [option, arg, lazy] of journal.ops) raw(withMotion(resolveColors(el, option) as Option), arg, lazy);
}
/**
 * Why: themes are live - dark mode toggles a class on <html>, the theme
 * switcher writes token overrides into <html style> or swaps a token
 * <style>. One document-level observer re-derives every live chart
 * (ECharts' setTheme re-renders from the stored option - no remount, no
 * lost state). Debounced to one pass per burst of mutations.
 */
let themeWatched = false;
function watchTheme(): void {
  if (themeWatched) return;
  themeWatched = true;
  let timer = 0;
  const schedule = (): void => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      for (const el of live) {
        const inst = instances.get(el);
        if (!el.isConnected || !inst || inst.isDisposed?.()) {
          live.delete(el);
          continue;
        }
        retheme(el, inst);
      }
    }, 60) as unknown as number;
  };
  const mo = new MutationObserver(schedule);
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
  if (document.head) mo.observe(document.head, { childList: true, subtree: true, characterData: true });
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', schedule);
}
// -- story + deck stage -----------------------------------------------------
/** Every series joins the morph unless it opted out: universalTransition is
 * what turns a state change into one continuous shape change. Custom series
 * are left alone - ECharts cannot morph renderItem geometry; they animate
 * through their own element `transition` / `enterFrom` instead. */
function morphable(option: Option): Option {
  const series = option.series;
  if (series === undefined) return option;
  const list = (Array.isArray(series) ? series : [series]) as Option[];
  return {
    ...option,
    series: list.map((s) => (s.universalTransition === undefined && s.type !== 'custom' ? { ...s, universalTransition: { enabled: true } } : s)),
  };
}
/**
 * Why: data-storytelling - a sequence of option states driven like slides.
 * go() clamps (or wraps with { loop: true }) and applies states[i] with
 * notMerge, so each step is a full surface (the token theme persists - it
 * is the instance's theme, not part of the option). Series default to
 * universalTransition, so keeping series.id and data names stable across
 * states makes ECharts MORPH instead of redrawing. An unmounted element is
 * lazily mounted with states[0].
 */
export function chartStory(
  el: HTMLElement,
  states: Option[],
  { loop = false }: { loop?: boolean } = {},
): { next(): number; prev(): number; go(i: number): number; index(): number } {
  if (!Array.isArray(states) || states.length === 0) {
    throw new Error('chart: chartStory needs at least one option state');
  }
  if (!instances.has(el)) mount(el, morphable(states[0]));
  let i = 0;
  const go = (n: number): number => {
    i = loop ? ((n % states.length) + states.length) % states.length : Math.min(Math.max(n, 0), states.length - 1);
    instances.get(el)?.setOption(morphable(states[i]), true);
    return i;
  };
  return { next: () => go(i + 1), prev: () => go(i - 1), go, index: () => i };
}
/** Handle returned by df$.shadcn.chart.deck(). */
export interface ChartDeck {
  /** Show a named state (morphing from the current one), or hide the stage with null. */
  show(name: string | null): void;
  /** The state currently on stage (null while hidden). */
  state(): string | null;
  /** Stop following the deck and dispose the chart. */
  dispose(): void;
}
/**
 * Why: the deck stage - ONE chart instance for a whole presentation, so
 * every chart slide MORPHS into the next (bars → dots → donut …) instead of
 * cutting between separate charts. The stage is a `.chart.presentation-
 * stage` child of the `.presentation` mount, laid out in artboard
 * coordinates by presentation.css; slides name the state they show with
 * data-chart-state="name". Slides without one fade the stage out - the
 * instance keeps its last state, so the next chart slide morphs from there.
 *
 * Each state is deep-merged over `base` (shared chrome) and applied with
 * notMerge, so a state is a complete surface; series default to
 * universalTransition. The first appearance mounts the chart, so its
 * entrance animation plays on stage - never hidden at page load.
 */
/** Deck tempo: entrances and morphs take the slides' 1.5s - slow and legible
 * at presentation distance (a base or state may still override it). */
const DECK_TEMPO: Option = { animationDuration: 1500, animationEasing: 'cubicOut', animationDurationUpdate: 1500, animationEasingUpdate: 'cubicInOut' };
export function chartDeck(deck: HTMLElement, { base = {}, states }: { base?: Option; states: Record<string, Option> }): ChartDeck {
  const stage = deck.querySelector<HTMLElement>(':scope > .presentation-stage');
  if (!stage) throw new Error('chart: chart.deck() needs a <div class="chart presentation-stage"> child of the .presentation');
  if (!isPlain(states) || Object.keys(states).length === 0) throw new Error('chart: chart.deck() needs at least one named state');
  const slides = Array.from(deck.querySelectorAll<HTMLElement>(':scope > [data-slide]'));
  let current: string | null = null;
  let last: string | null = null;
  const show = (name: string | null): void => {
    if (name === null || !(name in states)) {
      stage.removeAttribute('data-visible');
      current = null;
      return;
    }
    // the stage inherits its text color from the slide it plays on, once:
    // the theme is fixed at mount, states own every color after that
    const slide = slides.find((s) => s.dataset.chartState === name);
    if (!instances.has(stage) && slide) stage.style.color = getComputedStyle(slide).color;
    stage.setAttribute('data-visible', '');
    if (name === current) return;
    const returning = current === null && name === last; // same chart, stage was away
    current = name;
    last = name;
    const option = morphable(deepMerge(deepMerge(DECK_TEMPO, base), states[name]));
    const inst = instances.get(stage);
    if (!inst) mount(stage, option);
    else if (returning) {
      // nothing to morph from - replay the chart's entrance instead, so
      // every arrival on a chart slide animates
      inst.clear?.();
      inst.setOption(option, true);
    } else inst.setOption(option, true);
  };
  const sync = (): void => {
    const active = slides.find((s) => s.hasAttribute('data-active'));
    show(active?.dataset.chartState ?? null);
  };
  const mo = new MutationObserver(sync);
  slides.forEach((s) => mo.observe(s, { attributes: true, attributeFilter: ['data-active'] }));
  sync();
  return {
    show,
    state: () => current,
    dispose: () => {
      mo.disconnect();
      observers.get(stage)?.disconnect();
      instances.get(stage)?.dispose();
      instances.delete(stage);
      live.delete(stage);
      stage.removeAttribute('data-visible');
    },
  };
}
// -- State API ------------------------------------------------------------
/**
 * UI side of setState: 'default' re-applies the chart surface - config.option
 * replaces the option wholesale (notMerge), so a reset returns to exactly
 * the given surface; a bare setState('default') is a no-op on the DOM.
 * Unknown names throw.
 */
function triggerStateChange(el: HTMLElement, stateName: string, config: Option = {}): void {
  if (!chartStates.includes(stateName)) {
    throw new Error(`chart: unknown state "${stateName}" (supported: ${chartStates.join(', ')})`);
  }
  if (isPlain(config.option)) {
    const current = instances.get(el);
    if (current) current.setOption(config.option, true);
    else mount(el, config.option);
  }
}
/** Registry-level API; pass the mount explicitly. Unknown names throw. */
export const chartApi = {
  setState(el: HTMLElement, stateName: string, config: Option = {}) {
    triggerStateChange(el, stateName, config);
    // state lives on the ELEMENT, not module scope (AGENTS.md "State API")
    el.dataset.stateName = stateName;
    el._stateConfig = config;
  },
  getState(el: HTMLElement) {
    return {
      name: el.dataset.stateName || 'default',
      config: { ...el._stateConfig },
    };
  },
};
df$.chartApi = chartApi;
df$.chartStates = chartStates;
df$.chart = { mount, instance, theme: chartTheme, color: chartColor, deck: chartDeck };
df$.chartStory = chartStory;
function init(): void {
  document.querySelectorAll<HTMLElement>('.chart:not([data-init])').forEach((el) => {
    el.dataset.init = '';
    // bind-scope the api per instance: `$('#c').api.setState('default', …)`
    el.api = {
      setState: (stateName: string, config?: Option) => chartApi.setState(el, stateName, config),
      getState: () => chartApi.getState(el),
    };
    // declarative mount: data-chart JSON → echarts instance. Zero-size boxes
    // (display:none hosts, pre-layout SPA swaps) defer - the observer retries
    // once the box has real width+height; the instance guard makes it once-only.
    const boot = (): void => {
      if (instances.has(el)) return;
      const raw = el.getAttribute('data-chart');
      if (!raw) return;
      const box = el.getBoundingClientRect();
      if (box.width === 0 || box.height === 0) return;
      let option: Option;
      try {
        option = JSON.parse(raw) as Option;
      } catch (err) {
        throw new Error(`chart: invalid JSON in data-chart - ${err instanceof Error ? err.message : err}`);
      }
      mount(el, option);
    };
    new ResizeObserver(boot).observe(el);
    boot();
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub