Theme
Design your own
On this page (5)
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).

§API

Generated from chart.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type ChartState = 'default' - setState(name, config) takes the config of the state it names.

StateDescription
default
The rendered chart surface; a bare setState('default') changes nothing.
Config fieldTypeDescription
option?Optiona whole new ECharts option - replaces the current one (notMerge); the first one mounts the chart

Every element

MemberDescription
el.api.setState<S extends ChartState>(name: S, config?: ChartStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ChartStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: ChartState; config: ChartStateConfigs[ChartState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.chartApi.setState<S extends ChartState>(el: HTMLElement, name: S, config?: ChartStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ChartStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.chartApi.getState(el: HTMLElement): { name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.chartApi.render(state: { name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: ChartState; config: ChartStateConfigs[ChartState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.chartApi.store(el: HTMLElement): Store<{ name: ChartState; config: ChartStateConfigs[ChartState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ChartState; config: ChartStateConfigs[ChartState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.chartApi.commit<S extends ChartState>(el: HTMLElement, name: S, config?: ChartStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ChartStateConfigs[S]its config
df$.shadcn.chartStates: ChartState[]The declared states, 'default' first: default.

df$.shadcn.chart

MemberDescription
mount(el: HTMLElement, option: Option = {}): ChartMount
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.
ArgumentTypeDescription
elHTMLElementthe .chart element to draw into
optionOption = {}the first option

Returns ChartMount - the instance and its setOption / dispose

instance(el: HTMLElement): EChartsInstanceLike | undefined
The stored instance for an element.
ArgumentTypeDescription
elHTMLElementthe .chart element

Returns EChartsInstanceLike | undefined - its ECharts instance, undefined until mounted

theme(el: HTMLElement): Option
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.
ArgumentTypeDescription
elHTMLElementthe .chart element whose tokens and text color the theme reads

Returns Option - an ECharts theme object (pass it to init or setTheme)

color(el: HTMLElement, value: string, alpha: number = 1): string
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.
ArgumentTypeDescription
elHTMLElementthe .chart element (a token resolves against its computed style)
valuestringa token name ('--chart-2') or any CSS color
alphanumber = 1opacity multiplier, 0 to 1

Returns string - rgb() / rgba() ECharts can parse, '' when the value does not resolve

deck(deck: HTMLElement, { base = {}, states }: { base?: Option; states: Record<string, Option> }): ChartDeck
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.
ArgumentTypeDescription
deckHTMLElementthe .presentation element holding the .chart.presentation-stage
{ base = {}, states }{ base?: Option; states: Record<string, Option> }base: the chrome every state shares; states: the option of each named state

Returns ChartDeck - the stage's controls

df$.shadcn.chartStory

MemberDescription
df$.shadcn.chartStory(el: HTMLElement, states: Option[], { loop = false }: { loop?: boolean } = {}): ChartStory
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].
ArgumentTypeDescription
elHTMLElementthe .chart element
statesOption[]the option of each step, in order (at least one)
{ loop = false }{ loop?: boolean } = {}loop: true wraps past the ends instead of clamping

Returns ChartStory - the story's controls

Types

TypeDescription
ChartDeck
Handle returned by df$.shadcn.chart.deck().
FieldTypeDescription
show(name: string | null) => voidShow a named state (morphing from the current one), or hide the stage with null.
state() => string | nullThe state currently on stage (null while hidden).
dispose() => voidStop following the deck and dispose the chart.
ChartMount
Handle returned by df$.chart.mount() - the imperative lifecycle surface.
FieldTypeDescription
instanceEChartsInstanceLikethe ECharts instance
setOption(option: Option, notMerge?: boolean) => voidapply an option (token colors resolved, motion settings respected)
dispose() => voidstop observing the element and release the instance
ChartStory
Handle returned by df$.shadcn.chartStory() - drive the states like slides.
FieldTypeDescription
next() => numbergo to the next state; returns its index
prev() => numbergo to the previous state; returns its index
go(i: number) => numbergo to state i (clamped, or wrapped with loop); returns the index shown
index() => numberthe index shown now
EChartsInstanceLike
Minimal structural view of the vendor global (read via globalThis, never window).
FieldTypeDescription
setOption(option: Option, notMerge?: boolean) => voidapply an option - merged, or replacing the current one with notMerge
setTheme?(theme: Option) => voidswap the theme (ECharts 6)
resize() => voidfit the chart to its element's size
clear?() => voidremove every series and component
dispose() => voidrelease the instance
isDisposed?() => booleanwhether dispose() ran
getOption() => Optionthe option as ECharts holds it now
Option
An ECharts option object - plain JSON (series, axes, legend, ...), token names allowed as colors.

= Record<string, unknown>

§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%;
    /* a grid or flex item sizes no smaller than its content by default - the
       canvas drawn at the old width held a narrowing figure open, so the chart
       never saw its container shrink (the paper on a phone after a resize) */
    min-inline-size: 0;
    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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
const chartStates = ['default'];
/** setState() configs per state. */
export interface ChartStateConfigs {
  /** The rendered chart surface; a bare setState('default') changes nothing. */
  default: {
    /** a whole new ECharts option - replaces the current one (notMerge); the first one mounts the chart */
    option?: Option;
  };
}
/** 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';
/** An ECharts option object - plain JSON (series, axes, legend, ...), token names allowed as colors. */
type Option = Record<string, unknown>;
/** Minimal structural view of the vendor global (read via globalThis, never window). */
interface EChartsInstanceLike {
  /** apply an option - merged, or replacing the current one with notMerge */
  setOption(option: Option, notMerge?: boolean): void;
  /** swap the theme (ECharts 6) */
  setTheme?(theme: Option): void;
  /** fit the chart to its element's size */
  resize(): void;
  /** remove every series and component */
  clear?(): void;
  /** release the instance */
  dispose(): void;
  /** whether dispose() ran */
  isDisposed?(): boolean;
  /** the option as ECharts holds it now */
  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 {
  /** the ECharts instance */
  instance: EChartsInstanceLike;
  /** apply an option (token colors resolved, motion settings respected) */
  setOption(option: Option, notMerge?: boolean): void;
  /** stop observing the element and release the instance */
  dispose(): void;
}
/** Handle returned by df$.shadcn.chartStory() - drive the states like slides. */
interface ChartStory {
  /** go to the next state; returns its index */
  next(): number;
  /** go to the previous state; returns its index */
  prev(): number;
  /** go to state i (clamped, or wrapped with loop); returns the index shown */
  go(i: number): number;
  /** the index shown now */
  index(): number;
}
/** 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.
 * @param el - the .chart element (a token resolves against its computed style)
 * @param value - a token name ('--chart-2') or any CSS color
 * @param alpha - opacity multiplier, 0 to 1
 * @returns rgb() / rgba() ECharts can parse, '' when the value does not resolve
 */
export function chartColor(el: HTMLElement, value: string, alpha: number = 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.
 * @param el - the .chart element whose tokens and text color the theme reads
 * @returns an ECharts theme object (pass it to init or setTheme)
 */
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.
 * @param el - the .chart element to draw into
 * @param option - the first option
 * @returns the instance and its setOption / dispose
 */
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.
 * @param el - the .chart element
 * @returns its ECharts instance, 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].
 * @param el - the .chart element
 * @param states - the option of each step, in order (at least one)
 * @param options - loop: true wraps past the ends instead of clamping
 * @returns the story's controls
 */
export function chartStory(
  el: HTMLElement,
  states: Option[],
  { loop = false }: { loop?: boolean } = {},
): ChartStory {
  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;
}
/** 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' };
/**
 * 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.
 * @param deck - the .presentation element holding the .chart.presentation-stage
 * @param options - base: the chrome every state shares; states: the option of each named state
 * @returns the stage's controls
 */
export function chartDeck(deck: HTMLElement, { base = {}, states }: { base?: Option; states: Record<string, Option> }): ChartDeck {
  const stage = ((dfDollar(deck).find(':scope > .presentation-stage').get(0) ?? null) as HTMLElement | null);
  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((dfDollar(deck).find(':scope > [data-slide]').toArray() as HTMLElement[]));
  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 ------------------------------------------------------------
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(_el: Element, _stateName: string): void {
  // one state, and it writes no markup: { option } feeds the ECharts
  // instance, which draws INSIDE the host (runtime-owned, see the e2e)
}
/**
 * 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 = componentState({
  component: 'chart',
  states: chartStates,
  apply: (el, state) => triggerStateChange(el, state.name, state.config),
  read: (el, state) => {
    return {
      name: el.dataset.stateName || 'default',
      config: { ...state.config },
    };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.chartApi = chartApi;
df$.chartStates = chartStates;
df$.chart = { mount, instance, theme: chartTheme, color: chartColor, deck: chartDeck };
df$.chartStory = chartStory;
function init(): void {
  (dfDollar('.chart:not([data-init])').toArray() as HTMLElement[]).forEach((el) => {
    el.dataset.init = '';
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(el, chartApi);
    // 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