From 95c54c280e187c759f71fe87d4679d957a121b34 Mon Sep 17 00:00:00 2001 From: bsevern Date: Thu, 28 May 2026 13:05:37 -0400 Subject: [PATCH] feat: opt-in data-change transitions on core charts (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDataTransition / interpolateChartData / prefersReducedMotion already exist in goldenchart/interactive but no chart consumed them by default, so changing `data` mid-mount snapped instead of animating. - new `transitions?: { enabled?, durationMs? }` on BaseChartProps (default `enabled: false` so existing renders stay byte-identical) - BarChart, LineChart, AreaChart, PieChart wrap their data prop in useDataTransition; ScatterPlot deferred (needs ScatterDatum support in interpolateChartData — separate issue) - honours prefers-reduced-motion via the existing hook - docs/INTERACTIVITY.md gains a Data-change transitions section - Rough.js seeds are index-based; verified stable across frames so hand-drawn strokes don't shimmer mid-tween Bundle 51 KB gzipped (budget 75 KB; +1 KB from pulling useDataTransition into the main entry). --- docs/INTERACTIVITY.md | 20 ++++++++++++++++++++ src/components/AreaChart.tsx | 9 ++++++++- src/components/BarChart.tsx | 9 ++++++++- src/components/LineChart.tsx | 9 ++++++++- src/components/PieChart.tsx | 9 ++++++++- src/types/charts.ts | 10 ++++++++++ 6 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/INTERACTIVITY.md b/docs/INTERACTIVITY.md index 05260c9..393497e 100644 --- a/docs/INTERACTIVITY.md +++ b/docs/INTERACTIVITY.md @@ -123,6 +123,26 @@ const html = interactiveEmbed(svgString, { title: 'Sales' }); The MCP server exposes the same via the `export_interactive_html` tool, so an agent can emit an interactive chart a reader can hover — not just a static image. +## Data-change transitions + +Pass `transitions={{ enabled: true }}` to `BarChart`, `LineChart`, `AreaChart`, +or `PieChart` and the chart will tween between data snapshots (400 ms by +default; override with `durationMs`). Off by default — existing renders are +byte-identical. Honors `prefers-reduced-motion` (snaps instead of tweening). + +```tsx + +``` + +Under the hood the chart wraps its `data` prop in `useDataTransition` +(exported from `goldenchart/interactive` for direct use in custom +compositions). Rough.js seeds are index-based and don't shimmer across frames. + ## Accessibility Marks are keyboard-focusable with `aria-label`s; the tooltip path is reachable diff --git a/src/components/AreaChart.tsx b/src/components/AreaChart.tsx index 3f48f62..abdae96 100644 --- a/src/components/AreaChart.tsx +++ b/src/components/AreaChart.tsx @@ -9,6 +9,7 @@ import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { seriesTable } from '../core/dataTable'; import { describeSeries } from '../core/a11yDescribe'; +import { useDataTransition } from '../interactive/useDataTransition'; import { layoutLegend } from '../core/legend'; import type { LegendItem } from '../core/legend'; import { resolveVibe } from '../vibe/resolveVibe'; @@ -44,7 +45,7 @@ export interface AreaChartProps extends BaseChartProps { * d3-shape's `area` builds the fill path; the vibe's `fillStyle` textures it. */ export function AreaChart({ - series, + series: rawSeries, width, height, margin, @@ -67,7 +68,13 @@ export function AreaChart({ annotations, xAxis, yAxis, + transitions, }: AreaChartProps) { + const series = useDataTransition( + rawSeries, + transitions?.durationMs ?? 400, + transitions?.enabled ?? false, + ); const fullPlot = getPlotArea(width, height, margin); const rv = resolveVibe(vibe); const palette = resolveBrand(brand).palette; diff --git a/src/components/BarChart.tsx b/src/components/BarChart.tsx index 4f99571..791d5a9 100644 --- a/src/components/BarChart.tsx +++ b/src/components/BarChart.tsx @@ -13,6 +13,7 @@ import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { datumTable } from '../core/dataTable'; import { describeBars } from '../core/a11yDescribe'; +import { useDataTransition } from '../interactive/useDataTransition'; import { groupMax, seriesKeysOf, stackLayout, stackMax } from '../core/stack'; import { Surface } from './Surface'; import { Axis } from './Axis'; @@ -63,7 +64,7 @@ interface LaidBar { * multi-series modes. */ export function BarChart({ - data, + data: rawData, width, height, margin, @@ -84,7 +85,13 @@ export function BarChart({ annotations, xAxis, yAxis, + transitions, }: BarChartProps) { + const data = useDataTransition( + rawData, + transitions?.durationMs ?? 400, + transitions?.enabled ?? false, + ); const fullPlot = getPlotArea(width, height, margin); const resolved = resolveVibe(vibe); const palette = resolveBrand(brand).palette; diff --git a/src/components/LineChart.tsx b/src/components/LineChart.tsx index 1425ca5..2aa2716 100644 --- a/src/components/LineChart.tsx +++ b/src/components/LineChart.tsx @@ -9,6 +9,7 @@ import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { seriesTable } from '../core/dataTable'; import { describeSeries } from '../core/a11yDescribe'; +import { useDataTransition } from '../interactive/useDataTransition'; import { layoutLegend } from '../core/legend'; import type { LegendItem } from '../core/legend'; import { resolveVibe } from '../vibe/resolveVibe'; @@ -42,7 +43,7 @@ export interface LineChartProps extends BaseChartProps { /** Multi-series line chart: d3-shape builds each path, `` sketches it. */ export function LineChart({ - series, + series: rawSeries, width, height, margin, @@ -64,7 +65,13 @@ export function LineChart({ emphasis, xAxis, yAxis, + transitions, }: LineChartProps) { + const series = useDataTransition( + rawSeries, + transitions?.durationMs ?? 400, + transitions?.enabled ?? false, + ); const fullPlot = getPlotArea(width, height, margin); const rv = resolveVibe(vibe); const palette = resolveBrand(brand).palette; diff --git a/src/components/PieChart.tsx b/src/components/PieChart.tsx index 8c7fd9f..824393e 100644 --- a/src/components/PieChart.tsx +++ b/src/components/PieChart.tsx @@ -6,6 +6,7 @@ import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { datumTable } from '../core/dataTable'; import { describePie } from '../core/a11yDescribe'; +import { useDataTransition } from '../interactive/useDataTransition'; import { resolveVibe } from '../vibe/resolveVibe'; import { Surface } from './Surface'; import { RoughPath } from '../primitives/RoughPath'; @@ -25,7 +26,7 @@ export interface PieChartProps extends BaseChartProps { * origin; we translate to the plot center and let `` sketch them. */ export function PieChart({ - data, + data: rawData, width, height, margin, @@ -41,7 +42,13 @@ export function PieChart({ innerRadius = 0, padAngle = 0.02, showLabels = true, + transitions, }: PieChartProps) { + const data = useDataTransition( + rawData, + transitions?.durationMs ?? 400, + transitions?.enabled ?? false, + ); const plot = getPlotArea(width, height, margin); const cx = plot.x + plot.width / 2; const cy = plot.y + plot.height / 2; diff --git a/src/types/charts.ts b/src/types/charts.ts index 8e84a79..891a2d6 100644 --- a/src/types/charts.ts +++ b/src/types/charts.ts @@ -60,6 +60,16 @@ export interface BaseChartProps { children?: ReactNode; /** Render only the bare `` (no wrapper div) for headless/SVG-string use. */ bare?: boolean; + /** + * Opt-in enter/update transitions when the chart's data prop changes. Off + * by default so existing renders are byte-identical; honours + * `prefers-reduced-motion` (snaps to the new state instead of tweening). + */ + transitions?: { + enabled?: boolean; + /** Tween length in ms. Default 400. */ + durationMs?: number; + }; } /** A tabular mirror of a chart's data, rendered visually-hidden for screen readers. */