Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/s2-animation-ticker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@spectrum-charts/react-spectrum-charts-s2': patch
'@spectrum-charts/vega-spec-builder-s2': patch
'@spectrum-charts/constants': minor
---

S2 animations run on a shared on-demand ticker instead of each chart's always-on Vega timer.

- Idle charts do no animation work; off-screen charts pause.
- Animations run at the display's native refresh rate, within an 8ms per-frame budget across charts.
- Draw-in starts on the first painted frame, so slow mounts no longer skip most of the animation.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ tmp/
.agents/
.scout/
.claude/rules/

# performance benchmark output
perf-results/
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"start:docs": "yarn workspace @spectrum-charts/docs start",
"storybook": "cross-env NODE_OPTIONS=--openssl-legacy-provider && storybook dev -p 6009",
"storybook:s2": "cross-env NODE_OPTIONS=--openssl-legacy-provider && storybook dev -p 6010 --config-dir .storybook-s2",
"perf:animation": "node scripts/perf/animationBenchmark.mjs",
"build:storybook:s2": "storybook build --config-dir .storybook-s2 -o ./dist-storybook-s2 --quiet",
"test": "cross-env TZ=UTC BABEL_ENV=test jest",
"test:quiet": "cross-env TZ=UTC BABEL_ENV=test jest --coverage=false",
Expand Down
7 changes: 6 additions & 1 deletion packages/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,12 @@ export const SELECTED_GROUP = 'selectedGroup'; // data point
export const FIRST_RSC_SERIES_ID = 'firstRscSeriesId'; // first series for dual y-axis
export const LAST_RSC_SERIES_ID = 'lastRscSeriesId'; // last series for dual y-axis
export const ANIMATION_TIMER = 'animationTimer'; // main animation timer signal
export const ANIMATION_ACTIVE = 'animationActive'; // true while any animation (or its final grace tick) still needs timer ticks
export const HOVER_TARGETS = 'hoverTargets'; // hover animation target values
export const HOVER_ANIMATING = 'hoverAnimating'; // hover animation state signal
export const HOVER_ACTIVE_TIMER = 'hoverActiveTimer'; // animation timer to run only when hoverAnimating is true
export const HOVER_IDLE_TICKS = 'hoverIdleTicks'; // gates hoverActiveTimer's one-tick grace period after hoverAnimating goes false
export const DRAW_IN_START = 'drawInStart'; // mount timestamp, captured once
export const DRAW_IN_START = 'drawInStart'; // timestamp of the first animation timer tick, captured once
export const DRAW_IN_ANIM_T = 'drawInAnimT'; // linear 0->1 progress, throttled timer
export const DRAW_IN_ANIM_T_EASED = 'drawInAnimTEased'; // eased (quadratic in-out) progress
export const DRAW_IN_DOMAIN_MIN = 'drawInDomainMin'; // draw-in animation: dimension scale domain min, captured once at mount
Expand Down Expand Up @@ -218,6 +219,10 @@ export const DEFAULT_ANIMATION_TYPES: AnimationType[] = ['hover'];
// hover animation constants
/** Timer signal update interval in ms. Caps timer signal update at ~30fps. */
export const ANIMATION_THROTTLE = 33;
/** Minimum ms between host animation ticker frames. 0 = native display rate; set to ANIMATION_THROTTLE for ~30fps. */
export const ANIMATION_MIN_FRAME_INTERVAL = 0;
/** Per-frame time budget (ms) for ticking animated charts; charts past the budget tick on the next frame */
export const ANIMATION_FRAME_BUDGET_MS = 8;
/** Time in ms it takes to animate between hover states (hovered -> unhovered etc.) */
export const ANIMATION_HOVER_SPEED = 250;
/**
Expand Down
59 changes: 59 additions & 0 deletions packages/react-spectrum-charts-s2/src/VegaChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@ import { render, waitFor } from '@testing-library/react';
import { Spec, View, expressionFunction } from 'vega';
import embed from 'vega-embed';

import { ANIMATION_TIMER } from '@spectrum-charts/constants';

import { VegaChart, VegaChartProps, resizeView } from './VegaChart';
import { attachAnimationTicker } from './animation/animationTicker';

jest.mock('vega-embed');
jest.mock('./animation/animationTicker', () => ({
...jest.requireActual('./animation/animationTicker'),
attachAnimationTicker: jest.fn(),
}));

const mockAttachAnimationTicker = jest.mocked(attachAnimationTicker);
const mockDetachAnimationTicker = jest.fn();

const mockEmbed = jest.mocked(embed);

Expand Down Expand Up @@ -118,6 +128,7 @@ describe('VegaChart init render cycle', () => {
beforeEach(() => {
jest.clearAllMocks();
mockEmbed.mockResolvedValue({ view: createMockView() } as unknown as Awaited<ReturnType<typeof embed>>);
mockAttachAnimationTicker.mockReturnValue(mockDetachAnimationTicker);
});

test('calls embed on initial mount with valid dimensions', async () => {
Expand All @@ -140,4 +151,52 @@ describe('VegaChart init render cycle', () => {

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
});

describe('animation ticker', () => {
const animatedSpec: Spec = {
signals: [{ name: ANIMATION_TIMER, value: 0, on: [{ events: { type: 'timer', throttle: 33 }, update: 'now()' }] }],
};

test('removes the vega timer event and attaches the ticker for animated specs', async () => {
const { container } = render(<VegaChart {...defaultProps} spec={animatedSpec} />);

await waitFor(() => expect(mockAttachAnimationTicker).toHaveBeenCalledTimes(1));
expect((mockEmbed.mock.calls[0][1] as Spec).signals).toEqual([{ name: ANIMATION_TIMER, value: 0 }]);
// the original spec is not mutated
expect(animatedSpec.signals?.[0]).toHaveProperty('on');
expect(mockAttachAnimationTicker).toHaveBeenCalledWith(expect.anything(), container.querySelector('.rsc'));
});

test('detaches the ticker on unmount', async () => {
const { unmount } = render(<VegaChart {...defaultProps} spec={animatedSpec} />);
await waitFor(() => expect(mockAttachAnimationTicker).toHaveBeenCalledTimes(1));

unmount();

expect(mockDetachAnimationTicker).toHaveBeenCalledTimes(1);
});

test('discards a view whose embed resolves after unmount', async () => {
let resolveEmbed: (value: Awaited<ReturnType<typeof embed>>) => void = () => {};
mockEmbed.mockReturnValueOnce(new Promise((resolve) => (resolveEmbed = resolve)));
const view = createMockView();
const { unmount } = render(<VegaChart {...defaultProps} spec={animatedSpec} />);
await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));

unmount();
resolveEmbed({ view } as unknown as Awaited<ReturnType<typeof embed>>);
await Promise.resolve();

expect(view.finalize).toHaveBeenCalledTimes(1);
expect(mockAttachAnimationTicker).not.toHaveBeenCalled();
expect(defaultProps.onNewView).not.toHaveBeenCalled();
});

test('does not attach the ticker for non-animated specs', async () => {
render(<VegaChart {...defaultProps} />);

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
expect(mockAttachAnimationTicker).not.toHaveBeenCalled();
});
});
});
25 changes: 23 additions & 2 deletions packages/react-spectrum-charts-s2/src/VegaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { getLocale } from '@spectrum-charts/locales';
import { ChartData, UserMeta, applyUserMetaConfigPatches, getVegaEmbedOptions } from '@spectrum-charts/vega-spec-builder-s2';

import { attachAnimationTicker, isAnimatedSpec, removeAnimationTimerEvents } from './animation/animationTicker';
import { useDebugSpec } from './hooks/useDebugSpec';
import { extractValues, isVegaData } from './hooks/useSpec';
import { ChartProps } from './types';
Expand Down Expand Up @@ -84,6 +85,7 @@
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const chartView = useRef<View | undefined>(undefined);
const detachAnimationTicker = useRef<(() => void) | undefined>(undefined);
const hasMounted = useRef(false);
// AN-445759: flipped to true when dimensions become valid post-mount with no existing view,
// forcing the embed effect to run even though width/height are not in its deps.
Expand Down Expand Up @@ -122,6 +124,7 @@
}, [width, height]);

useEffect(() => {
let cancelled = false;
if (width && height && containerRef.current) {
const specCopy = JSON.parse(JSON.stringify(spec)) as Spec;
const tableData = specCopy.data?.find((d) => d.name === TABLE);
Expand All @@ -139,18 +142,36 @@
const embedOptions = getVegaEmbedOptions({ locale, height, width, padding, renderer, config });
const { patches } = (specCopy.usermeta as UserMeta | undefined) ?? {};
const finalConfig = applyUserMetaConfigPatches(patches, embedOptions.config);

embed(containerRef.current, specCopy, { ...embedOptions, config: finalConfig, tooltip }).then(({ view }) => {
const isAnimated = isAnimatedSpec(specCopy);
if (isAnimated) {
// animated charts are driven by the shared animation ticker instead of Vega's always-on timer
removeAnimationTimerEvents(specCopy);
}
// captured so the async .then attaches the ticker to the element this view was embedded into
const container = containerRef.current;

embed(container, specCopy, { ...embedOptions, config: finalConfig, tooltip }).then(({ view }) => {
// cleanup already ran (unmount or re-embed) before embed resolved, so discard this view
if (cancelled) {
view.finalize();
return;
}
chartView.current = view;
if (isAnimated) {
detachAnimationTicker.current = attachAnimationTicker(view, container);
}
onNewView(view);
view.resize();
view.runAsync();
// One additional render to settle all resize calculations
setTimeout(() => view.runAsync(), 0);
});

Check warning on line 168 in packages/react-spectrum-charts-s2/src/VegaChart.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.

See more on https://sonarcloud.io/project/issues?id=adobe_react-spectrum-charts&issues=AaD9KUn4Q8AlE7IZlXFu&open=AaD9KUn4Q8AlE7IZlXFu&pullRequest=964
}
return () => {
cancelled = true;
// destroy the chart on unmount
detachAnimationTicker.current?.();
detachAnimationTicker.current = undefined;
if (chartView.current) {
chartView.current.finalize();
chartView.current = undefined;
Expand Down
Loading
Loading