ChartMOL
Data visualization on the State API: Apache ECharts does the drawing (vendor script, zero bytes shipped) while the component owns the lifecycle - declarative data-chart mounting, a theme adapter that reads the design tokens off getComputedStyle (themes and dark mode apply with no JS state), container-accurate resize, reduced-motion suppression, and df$.shadcn.chartStory() for data-storytelling transitions.
On this page (5)
§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.
| State | Description | ||||||
|---|---|---|---|---|---|---|---|
default | The rendered chart surface; a bare setState('default') changes nothing.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | |||||||||
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 | |||||||||
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.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
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
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
df$.shadcn.chartApi.store(el: HTMLElement): Store<{ name: ChartState; config: ChartStateConfigs[ChartState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
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.
| ||||||||||||
df$.shadcn.chartStates: ChartState[] | The declared states, 'default' first: default. |
df$.shadcn.chart
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | ||||||||||||
instance(el: HTMLElement): EChartsInstanceLike | undefined | The stored instance for an element.
Returns | ||||||||||||
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
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.
Returns |
df$.shadcn.chartStory
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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].
Returns |
Types
| Type | Description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
ChartDeck | Handle returned by df$.shadcn.chart.deck().
| ||||||||||||||||||||||||
ChartMount | Handle returned by df$.chart.mount() - the imperative lifecycle surface.
| ||||||||||||||||||||||||
ChartStory | Handle returned by df$.shadcn.chartStory() - drive the states like slides.
| ||||||||||||||||||||||||
EChartsInstanceLike | Minimal structural view of the vendor global (read via globalThis, never window).
| ||||||||||||||||||||||||
Option | An ECharts option object - plain JSON (series, axes, legend, ...), token names allowed as colors. = |
§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