Charts · CompositionMOL
Composition patterns on the chart component: small multiples, part-to-whole hierarchies (treemap, sunburst, waffle), and flow diagrams (theme river, sankey, chord). Every demo mounts through df$.shadcn.chart.mount() or declarative data-chart - palette, typography and tooltip chrome come from the active theme's tokens in light and dark.
On this page (9)
§Small multiples
Repeat the same axes six times and vary only the data - regional shape differences read at a glance, with no legend and no overloaded multi-series chart. Each panel carries its own title, baseline and end value; Metro, the fastest riser, is the only colored line.
§Theme river
A theme river is the right choice when the story is changing prominence among topics rather than precise point comparison. The streams take the theme palette; hovering one dims the rest.
§Treemap
Area encodes value: three top-level groups split into their contributions - fully declarative, the token palette colors the groups and card-colored gaps separate the tiles.
§Sunburst
Radial hierarchy: ring depth is the tree level, the angle of each wedge its share - declarative, with radial labels on the outer rings and hover focus on the ancestor path.
§Sankey flow
Weighted flow from acquisition channel through content type to outcome - link width carries the quantity, adjacency hover traces one path. The last column's labels flip to the left (levels[depth 2]) so nothing clips at the edge.
§Chord diagram
Inter-team dependencies as weighted arcs - the chord pattern reads mutual flow where a sankey would insist on direction. Hover a team to isolate its ribbons.
§Waffle dot matrix
One dot per percent: a square 10×10 grid makes shares countable at a glance. Each category is its own scatter series, so the legend (right) comes for free and the colors are the --chart-* tokens.
Patterns adapted from the ECharts design-language lab (echarts-feat/), re-skinned on the design tokens via the chart theme adapter. Colors are written as var(--token) (or color-mix() over tokens) - the runtime resolves them to sRGB for ECharts and re-resolves them after every theme or dark-mode switch.
§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, token-reference resolution, mount/resize lifecycle, chartStory, the deck stage 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