AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻译准备中
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Composable motion and expressive effects for web charts. Hover, update and morph animations that work on bar, line and donut charts, including SVG charts you've already built with D3 or Observable Plot, and Vega or Vega-Lite charts rendered as SVG. Presentation, analysis and reduced-motion modes are built in.
Each picture plays once and stops where its effect ends. The demo site has every effect to try, on the library's charts and on D3, Vega-Lite and hand-written ones, with the motion modes and reduced motion.
Status: 0.5.0 (October 2026). The effect-author API is under the 0.x promise; the charts, adapters, built-in effects and motion core can still change in a minor release. CHANGELOG.md lists what's new and what changed since 0.4.0.
travel, flash, wiggle, pulse, trace and the data effects threshold, delta and label, put together with sequence, parallel, only and stagger; and the transition effect morph, which chartSwitch plays to turn each mark of one chart into its mark in the next (a transition can't go into a combinator). An effect reads only a shared description of the mark, so one effect works on every chart, and effects of your own work everywhere the built-in ones do.npm install expressive-charts
It has no runtime dependencies. It's an ES module and CommonJS, each with its own types, and its JavaScript has no side effects, so a bundler keeps only what a page imports: a bar chart is about 31 KB gzipped, and everything 80 KB.
On a page with no bundler, the script-tag bundle defines one global, ExpressiveCharts, holding the main entry:
<div id="sales"></div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/expressive-charts.global.js"></script>
<script>
const { barChart } = ExpressiveCharts;
barChart("#sales", {
label: "Revenue by region",
data: [{ region: "North", revenue: 42 }, { region: "South", revenue: 35 }],
y: "region",
x: "revenue",
on: { hover: "wiggle" }
});
</script>
A bar chart where an arrow runs out a bar on hover, and the bars an update changes blink:
import { barChart, icons, travel } from "expressive-charts";
const chart = barChart("#sales", {
label: "Revenue by region", // the chart's accessible name
data: regions, // [{ region: "North", revenue: 42 }, …]
y: "region", // not numbers, so it's the category
x: "revenue",
sort: "value",
on: {
hover: travel(icons.arrow), // a core icon, in the theme's colours
update: "flash" // a preset: the bars that changed blink
}
});
await chart.update({ data: nextQuarter }); // values change, then rows move, by the motion mode
travel takes any icon: the core dot, arrow and chevron, the sport pack's ball and boot, or an SVG icon from a set such as Lucide or Heroicons. With the sport pack, a stacked bar tells a story on hover:
import { barChart, sequence, travel } from "expressive-charts";
import { cleat, soccerBall } from "expressive-charts/icons/sport";
barChart("#goals", {
label: "Goals and assists by player",
data: players, // [{ name: "Okafor", goals: 16, assists: 4 }, …]
y: "name",
stack: ["goals", "assists"],
on: { hover: sequence(travel(soccerBall, { along: "goals" }), travel(cleat, { along: "assists" })) } // a ball rolls out the goals, then a boot steps along the assists
});
The same story on bars you already draw with D3:
import { expressive, sequence, travel } from "expressive-charts";
import { cleat, soccerBall } from "expressive-charts/icons/sport";
// bars: your D3 selection of the stacked <rect>s. d3.stack() keeps a series' key on its <g>, so copy it onto each point:
// .data((series) => series.map((d) => Object.assign(d, { key: series.key }))).
const fx = expressive(bars, {
key: (d) => d.data.name, // a player's rects are one bar...
segment: (d) => d.key, // ...and its series are the bar's segments
value: (d) => d[1] - d[0], // what each rect shows, so a resize isn't an update
keyboard: true, // one tab stop; the arrow keys move between bars
markLabel: (d) => `${d.data.name}: ${d.data.goals} goals, ${d.data.assists} assists`,
on: { hover: sequence(travel(soccerBall, { along: "goals" }), travel(cleat, { along: "assists" })) }
});
fx.refresh(bars); // after your chart redraws
A line that turns red where goals fall below expected goals, with a ball riding its tip, and a season played with a pause control:
import { lineChart, only, parallel, playButton, playback, threshold, travel } from "expressive-charts";
import { soccerBall } from "expressive-charts/icons/sport";
const xg = lineChart("#xg", {
label: "Goals vs expected goals",
data: weeks, // [{ week: 1, goals: 0, xg: 1.2 }, …]: one row per x, a number or a Date
x: "week",
series: ["goals", "xg"],
legend: { entries: [{ label: "Goals below xG", color: "negative" }] },
on: { decorate: only("goals", parallel(threshold({ compareTo: "xg" }), travel(soccerBall, { ride: "on" }))) }
});
const season = playback({ from: 1, to: 38, duration: 6500, onFrame: (week) => xg.reveal(week) }); // 6.5 s; 1.95 s in Analysis
playButton("#play", season, { labels: { play: "Play season" } });
docs/api.md documents every export, with its options, their defaults and what it returns.
barChart draws bars or columns, plain or stacked; donutChart shares of a total; lineChart a line per series, straight or, with curve: "monotone", curved through every value without going past it, on a number axis or, when x gives Dates, a time axis in UTC (or timeZone: "local") worded in the page's locale, with a gap wherever a value is null or undefined, hovering every series at an x with a crosshair or, with hover: "point", the one point nearest the pointer, the other lines dimmed. Each sizes itself to its container, follows the light or dark theme, and has a tooltip, a legend, a data table and keyboard focus. Its handle has update(), which animates a change by the motion mode, and update({ on }) swaps its effects in place; focus(key); leave(), which ends its hover; and labels(). table.series heads a series' column in the data table.chartSwitch shows views of the same data one at a time, and turns each mark into its own mark in the next with morph(): a donut becoming columns, and back from wherever it is. Its on(), marks() and leave() are the view on show's, and it can be linked like a chart.expressive() reads keys, rows and geometry from SVG marks drawn by D3, Observable Plot or by hand, with only a key: rects as bars, circles as points, and paths, lines, polylines, polygons and ellipses as shapes, through their transforms. It draws on an overlay, leaving the host's styling alone; follows the host's own transitions. A curve, or a line drawn with d3's , is read from its own path data, with its gaps, and no effect draws inside them; describes anything else, or a mark as you'd rather have it. Observable Plot binds each rect or dot to its row's index, so pass ; Plot draws a new each time, so pass the new one's marks. The Plot accessors are tested against Plot 0.6.17. does the same for a Vega or Vega-Lite View rendered as SVG, and follows its renders; a line breaks where Vega-Lite leaves out a missing value, and a curve is read from the path Vega draws. Both change their effects in place with , as a chart does, and ends their hover.defineEffect takes a run that animates the mark and an end that draws where the run ends, which is all reduced motion shows. Both read only the mark's shared description, so this underline works along a bar, round a donut slice or along a line, on our charts and through expressive():
import { barChart, defineEffect } from "expressive-charts";
const underline = defineEffect({
async run(mark, ctx) {
const line = ctx.layer.path(mark.slice(0, 0), { stroke: "text-muted", width: 2 });
let drawn = 0;
ctx.onChange((next) => line.update((mark = next).slice(0, drawn))); // follows the mark when it moves
await ctx.tween({ duration: 300, timing: "effect", update: (p) => line.update(mark.slice(0, (drawn = p))) }).finished;
},
end(mark, ctx) {
const line = ctx.layer.path(mark.slice(0, 1), { stroke: "text-muted", width: 2 });
ctx.onChange((next) => line.update(next.slice(0, 1)));
}
});
barChart("#sales", { label: "Revenue by region", data: regions, y: "region", x: "revenue", on: { hover: underline } });
expressive-charts/testing has the conformance suite the built-in effects pass, for any test runner: await checkEffect(underline) throws one Error listing every check that failed, on every kind of mark. The effects guide covers the mark, drawing, timing, end states, data effects, composing, testing and transition effects.
| Import | What it is |
|---|---|
expressive-charts | The charts, adapters, effects, controls, icons, geometry and motion core. |
expressive-charts/icons/sport | A ball and a boot for travel: soccerBall, cleat, and sport for registerIcons. |
expressive-charts/testing | The conformance suite for effects: checkEffect, checkTransition, their fixtures, testContext and play. |
expressive-charts/style.css | The stylesheet the charts inject, as a file, for a page that loads it itself. |
The script-tag bundle, dist/expressive-charts.global.js, is the main entry alone.
NoInfer. The packed package is checked on TypeScript 5.4, 6 and 7, under bundler and node16 resolution, as an ES module and as CommonJS, with import "expressive-charts/style.css". Under the old node10 ("node"), TypeScript can't find the subpath entries' types.The first chart, adapter or control on a page injects one <style>; everything else is set through the CSSOM, which a strict policy allows, and the library has no eval. Under a policy with a style-src nonce, give the stylesheet your nonce. Under one with no inline styles at all, load the stylesheet as a file and inject nothing. Call configure() before creating charts:
import { configure } from "expressive-charts";
configure({ nonce: cspNonce }); // the injected <style> takes it
import { configure } from "expressive-charts";
import "expressive-charts/style.css"; // the same rules as a file (dist/expressive-charts.css)...
configure({ injectStyles: false }); // ...and nothing injected
The package lists its CSS under sideEffects, so bundlers keep that import. Charts follow the device's light or dark setting; theme them with --ec-* custom properties, which your CSS always overrides, and pin a theme with data-theme="light" or "dark" on any ancestor (the stylesheet).
role="group" named by its label, and each mark one image named by what it shows: "Okafor: 16 goals, 4 assists", or a slice's "Saved: 112 shots, 28%". markLabel words the names ("1 goal") and markDescription adds what they don't say.leave().announce: true a polite live region that says what each update changed.motionSwitch(). Nothing plays by itself, and a playback has its pause control in playButton() (WCAG 2.2.2).expressive() adds the same keyboard focus with keyboard: true, and one name per mark with markLabel.The demo pages with the library's charts on them have had a written WCAG 2.2 AA audit (a11y-audit.md), and the test suite runs axe's WCAG rules on every demo page in Chromium, Firefox and WebKit, in light and dark, at 320 px wide and under forced colours. Screen-reader scripts for NVDA and Chrome, with their results, are in a11y-check.md. The tooltip can be hovered, and dismissed with Escape. Known gap: VoiceOver hasn't been run.
SVG charts stay smooth up to a few hundred moving marks. Measured on demo/stress.html with npm run bench (headless Chromium, each case alone, on an Intel Core i7-12700KF, October 2026), and with Chrome's 4× CPU slowdown standing in for a mid-range phone:
| Case | Smooth up to | At 4× slowdown |
|---|---|---|
| A bar chart's staged update, every value changing | 100 bars | under 100 |
| Hover, dimming the other bars | 1,000 bars | 500 |
A line on a date axis, played with threshold and a riding icon | 5 years of days (1–2 series), 2 years (3–4) | 1 year (1 series) |
D3 columns in a transition, followed by watch | 500 | 20 |
The same, with refresh() at the end | 1,000 | 100 |
A chartSwitch with morph() | 200 pairs | 20 |
The first draw of 1,000 static bars takes about 90 ms, and of 10,000 about a second. architecture.md has the method and the numbers.
From 0.1.0 the library follows semantic versioning. While it's 0.x, the effect-author API (defineEffect, defineDataEffect, describe, matrix, offset, the effect contracts and expressive-charts/testing) breaks only in a minor release, with a migration note, and a deprecated name keeps working for one minor release. The charts, adapters, built-in effects, controls and motion core can still change in a minor release, and CHANGELOG.md lists every change. docs/api.md marks each promised name.
npm run typecheck checks: ball-and-cleat bars, a goals vs xG line with playback, a donut that morphs into columns, D3 charts, effects of your own and presets.npm run dev. bars.html, donut.html, line.html and morph.html put the library's charts beside D3, Vega-Lite and hand-written ones, with the same effects on each; season.html links three charts by matchweek and labels each change with delta() and label(); stress.html is the limits above; and lab.html is the single-file prototype the library grew out of.| Command | What it does |
|---|---|
npm run dev | Serves the demo/ folder with live reload |
npm run build | Builds the npm package (ESM + CommonJS, each with its own types, with the icons/sport and testing entries), a script-tag bundle (dist/expressive-charts.global.js) and the stylesheet as a file (dist/expressive-charts.css) |
npm run size | Measures the built package and fails when a size is over its budget in scripts/size-budget.json (see CONTRIBUTING.md) |
npm run pack-check | After a build: packs the package, installs it in a new project and checks every entry as ESM and CommonJS, with its types (see CONTRIBUTING.md) |
npm test | Runs the test suite, which also checks docs/api.md against the exports and type-checks this README's examples |
npm run test:visual:docker | Runs the screenshot tests of demo/bars.html, donut.html, line.html, morph.html, season.html and stress.html in Chromium, and their DOM checks and the accessibility checks (axe) in Chromium, Firefox and WebKit, in Playwright's Linux image (needs Docker; see CONTRIBUTING.md) |
npm run build:site | Builds the demo pages into site/, which .github/workflows/pages.yml publishes as the demo site |
npm run media | Captures README's pictures (docs/media/*.gif) from the demo pages, frame by frame, in Playwright's Linux image (needs Docker; see ) |
Requires Node.js 22 or newer (CI tests 22 and 24).
watch: true.defined()geometrydatum: (el) => rows[el.__data__]<svg>refresh()update({ on })leave()link() links hover across charts, chart switches and attachments by a value their marks share, such as a column's matchweek and the line's x there: hovering one hovers the others' marks with that value, with their emphasis, dimming and hover effects, as source: "linked". Linked charts show no tooltip and take no focus.travel carries an icon along a mark; flash, wiggle and pulse draw attention without changing a value; trace draws a highlighter stroke along a mark and leaves it there; threshold colours the parts of a mark below or above a reference; delta labels a mark's change since the last update ("+2"), and label any text worked out from its data, past the mark's own labels. Each goes in an effect slot (hover, enter, update, exit, decorate), as an effect or a preset's name ("wiggle").motionSwitch() is the viewer's own choice of mode. playback() plays a value through a range, and playButton() is its Play/Pause button.motion, tween() and animated(), for animations of your own that follow the same modes.npm run bench | Measures the stress page's cases in headless Chromium against npm run dev, unthrottled and at 4×, about 47 minutes; not in CI |
npm run typecheck | Checks types without building |