Charts: NarrativeMOL
Storytelling patterns on the Chart component: a state-machine narrative that steps one instance through successive claims, universal transitions that morph data identity across chart forms, an animated ranking race, and the custom-series boundary - bespoke glyphs on standard chart infrastructure.
On this page (6)
§Story: claims sequence
df$.shadcn.chartStory drives one chart instance through three narrative states - baseline, comparison, composition. Series id 'main', data names and universalTransition stay stable across states, so ECharts morphs instead of redrawing. Step with the buttons.
The story contract - why the morph works: state 1 series: [{ id: 'main', type: 'bar', universalTransition: true, data: [{ name: 'Alpha', value: 54 }, …] }] state 2 + series 'then' (comparison bars) - same id 'main', same data names → bars resize, no redraw state 3 series: [{ id: 'main', type: 'pie', … }] - same id + names → bars morph into slicesEach chartStory step applies its state with notMerge, so every state is acomplete surface - axes and legends from a previous step never leak through.§Morph: bar → donut → scatter
universalTransition preserves object identity across radically different chart forms: the same five data points morph between bar, donut and scatter instead of cutting away. The cycler steps every 2.4s; the buttons jump directly.
§Bar race
realtimeSort + valueAnimation let ranking changes animate, not just values: each frame re-sorts the bars and tweens the labels. Run race drives periodic setOption updates; reduced-motion users get the same frames with zero-duration tweens.
§Custom series boundary
The reuse boundary in one demo: axes, coordinates, tooltip and visualMap stay standard chart infrastructure - only renderItem draws bespoke geometry (wind-vector glyphs). Everything else comes from the theme adapter.
Patterns adapted from the ECharts design-language lab (echarts-feat/), re-skinned on the design tokens via the chart theme adapter.
§CSS view file
The chart sizing surface and editorial frame these patterns mount into.
/* -- 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 runtime every demo on this page converges on: token theme adapter, mount/resize lifecycle, chartStory and the State API.
// -- 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