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. */