From e1e10ccf595dbdaf322046c2a45fc78efb129b1f Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:56:52 -0600 Subject: [PATCH 1/7] perf(s2): on-demand animation ticker - Replace each chart's always-on Vega timer with a page-wide on-demand rAF ticker (animationTicker): idle charts do no work, off-screen charts pause, 8ms frame budget. - Specs emit an animationActive signal (hover and draw-in conditions) so the ticker knows when a chart can sleep; standalone Vega consumers keep the timer. - Draw-in clock starts on the first tick so the first painted frame is at 0% progress. - Add `yarn perf:animation` benchmark (drawIn/hover/idle scenarios, presets). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/s2-animation-ticker.md | 12 + .gitignore | 3 + package.json | 1 + packages/constants/constants.ts | 9 +- .../src/VegaChart.test.tsx | 43 +++ .../src/VegaChart.tsx | 15 +- .../src/animation/animationTicker.test.ts | 283 +++++++++++++++++ .../src/animation/animationTicker.ts | 202 ++++++++++++ .../src/line/lineSpecBuilder.test.ts | 8 +- .../src/line/lineSpecBuilder.ts | 3 +- .../src/marks/animationTimerUtils.test.ts | 63 ++++ .../src/marks/animationTimerUtils.ts | 57 ++++ .../src/marks/drawInAnimationUtils.test.ts | 83 ++++- .../src/marks/drawInAnimationUtils.ts | 35 +- .../src/marks/hoverAnimationUtils.test.ts | 41 ++- .../src/marks/hoverAnimationUtils.ts | 34 +- .../hover-animation-system.md | 50 +++ scripts/perf/animationBenchmark.mjs | 300 ++++++++++++++++++ 18 files changed, 1204 insertions(+), 38 deletions(-) create mode 100644 .changeset/s2-animation-ticker.md create mode 100644 packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts create mode 100644 packages/react-spectrum-charts-s2/src/animation/animationTicker.ts create mode 100644 packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts create mode 100644 packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts create mode 100644 scripts/perf/animationBenchmark.mjs diff --git a/.changeset/s2-animation-ticker.md b/.changeset/s2-animation-ticker.md new file mode 100644 index 0000000000..7ce643c1e9 --- /dev/null +++ b/.changeset/s2-animation-ticker.md @@ -0,0 +1,12 @@ +--- +'@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. +- Add `yarn perf:animation` to benchmark draw-in, hover and idle cost in Storybook. diff --git a/.gitignore b/.gitignore index 6e3cdfce88..ade371d579 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ tmp/ .agents/ .scout/ .claude/rules/ + +# performance benchmark output +perf-results/ diff --git a/package.json b/package.json index d5c5ba74f5..b9761d2d7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts index b074447e5f..794b870f97 100644 --- a/packages/constants/constants.ts +++ b/packages/constants/constants.ts @@ -120,6 +120,8 @@ export const FILTERED_TABLE = 'filteredTable'; export const CONTROLLED_HIGHLIGHTED_TABLE = 'controlledHighlightedTable'; /** Single-row data source recording timestamp of the most recent hover target change */ export const HOVER_ANIM_LAST_CHANGE_DATA = 'hoverAnimLastChangeData'; +/** Single-row data source recording whether the draw-in animation has finished */ +export const DRAW_IN_CLOCK_DATA = 'drawInClockData'; export const HOVER_TARGET_DATA = 'hoverTargetData'; export const HOVER_ANIM_STATE_DATA = 'hoverAnimStateData'; export const HOVER_FRACTION_DATA = 'hoverFractionData'; @@ -169,11 +171,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 @@ -218,6 +221,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; /** diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index 4961ca085f..909f104970 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -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); @@ -118,6 +128,7 @@ describe('VegaChart init render cycle', () => { beforeEach(() => { jest.clearAllMocks(); mockEmbed.mockResolvedValue({ view: createMockView() } as unknown as Awaited>); + mockAttachAnimationTicker.mockReturnValue(mockDetachAnimationTicker); }); test('calls embed on initial mount with valid dimensions', async () => { @@ -140,4 +151,36 @@ 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(); + + 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(); + await waitFor(() => expect(mockAttachAnimationTicker).toHaveBeenCalledTimes(1)); + + unmount(); + + expect(mockDetachAnimationTicker).toHaveBeenCalledTimes(1); + }); + + test('does not attach the ticker for non-animated specs', async () => { + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + expect(mockAttachAnimationTicker).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.tsx index 98358a39a5..15072665bc 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.tsx @@ -19,6 +19,7 @@ import { TABLE } from '@spectrum-charts/constants'; 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'; @@ -84,6 +85,7 @@ export const VegaChart: FC = ({ }) => { const containerRef = useRef(null); const chartView = useRef(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. @@ -139,9 +141,18 @@ export const VegaChart: FC = ({ const embedOptions = getVegaEmbedOptions({ locale, height, width, padding, renderer, config }); const { patches } = (specCopy.usermeta as UserMeta | undefined) ?? {}; const finalConfig = applyUserMetaConfigPatches(patches, embedOptions.config); + const isAnimated = isAnimatedSpec(specCopy); + if (isAnimated) { + // animated charts are driven by the shared animation ticker instead of Vega's always-on timer + removeAnimationTimerEvents(specCopy); + } + const container = containerRef.current; - embed(containerRef.current, specCopy, { ...embedOptions, config: finalConfig, tooltip }).then(({ view }) => { + embed(container, specCopy, { ...embedOptions, config: finalConfig, tooltip }).then(({ view }) => { chartView.current = view; + if (isAnimated) { + detachAnimationTicker.current = attachAnimationTicker(view, container); + } onNewView(view); view.resize(); view.runAsync(); @@ -151,6 +162,8 @@ export const VegaChart: FC = ({ } return () => { // destroy the chart on unmount + detachAnimationTicker.current?.(); + detachAnimationTicker.current = undefined; if (chartView.current) { chartView.current.finalize(); chartView.current = undefined; diff --git a/packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts b/packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts new file mode 100644 index 0000000000..1943822eb8 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts @@ -0,0 +1,283 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Spec, View } from 'vega'; + +import { ANIMATION_ACTIVE, ANIMATION_FRAME_BUDGET_MS, ANIMATION_TIMER } from '@spectrum-charts/constants'; + +import { attachAnimationTicker, isAnimatedSpec, removeAnimationTimerEvents } from './animationTicker'; + +type Listener = (name: string, value: boolean) => void; + +/** Minimal stand-in for a vega View: animationActive stays true for `activeTicks` timer updates. */ +const createMockView = ({ activeTicks = 3, hasSignals = true } = {}) => { + let active = activeTicks > 0; + let ticksLeft = activeTicks; + const listeners: Listener[] = []; + const timerValues: number[] = []; + const view = { + signal: jest.fn((name: string, value?: number) => { + if (!hasSignals) throw new Error(`Unrecognized signal name: ${name}`); + if (value === undefined) return name === ANIMATION_ACTIVE ? active : timerValues.at(-1); + timerValues.push(value); + ticksLeft -= 1; + return view; + }), + runAsync: jest.fn(async () => { + const next = ticksLeft > 0; + if (next !== active) { + active = next; + listeners.forEach((l) => l(ANIMATION_ACTIVE, active)); + } + return view; + }), + addSignalListener: jest.fn((name: string, listener: Listener) => { + if (!hasSignals) throw new Error(`Unrecognized signal name: ${name}`); + listeners.push(listener); + return view; + }), + removeSignalListener: jest.fn((_name: string, listener: Listener) => { + listeners.splice(listeners.indexOf(listener), 1); + return view; + }), + }; + /** Simulates a hover change inside the dataflow that restarts the animation. */ + const startAnimation = (ticks: number) => { + ticksLeft = ticks; + active = true; + listeners.forEach((l) => l(ANIMATION_ACTIVE, true)); + }; + return { view: view as unknown as View, mock: view, timerValues, listeners, startAnimation }; +}; + +const flushFrames = async (frames: number) => { + for (let i = 0; i < frames; i++) { + jest.advanceTimersByTime(16); + // let the frame's sequential runAsync promises settle + for (let j = 0; j < 10; j++) await Promise.resolve(); + } +}; + +describe('animationTicker', () => { + let detachers: (() => void)[] = []; + const attach = (view: View, container: Element = document.createElement('div')) => { + const detach = attachAnimationTicker(view, container); + detachers.push(detach); + return detach; + }; + + beforeEach(() => { + jest.useFakeTimers(); + detachers = []; + }); + + afterEach(() => { + detachers.forEach((detach) => detach()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('isAnimatedSpec()', () => { + test('detects the animation timer signal', () => { + expect(isAnimatedSpec({ signals: [{ name: ANIMATION_TIMER, value: 0 }] } as Spec)).toBe(true); + expect(isAnimatedSpec({ signals: [{ name: 'other', value: 0 }] } as Spec)).toBe(false); + expect(isAnimatedSpec({} as Spec)).toBe(false); + }); + }); + + describe('removeAnimationTimerEvents()', () => { + test('strips the timer event from the animation timer signal only', () => { + const spec = { + signals: [ + { name: ANIMATION_TIMER, value: 0, on: [{ events: { type: 'timer', throttle: 33 }, update: 'now()' }] }, + { name: 'other', value: 0, on: [{ events: 'click', update: '1' }] }, + ], + } as Spec; + removeAnimationTimerEvents(spec); + expect(spec.signals).toStrictEqual([ + { name: ANIMATION_TIMER, value: 0 }, + { name: 'other', value: 0, on: [{ events: 'click', update: '1' }] }, + ]); + }); + + test('is a no-op for specs without the animation timer', () => { + const spec = {} as Spec; + removeAnimationTimerEvents(spec); + expect(spec).toStrictEqual({}); + }); + }); + + describe('attachAnimationTicker()', () => { + test('ticks an active view until animationActive goes false, then stops', async () => { + const { view, mock, timerValues } = createMockView({ activeTicks: 3 }); + attach(view); + await flushFrames(10); + expect(timerValues).toHaveLength(3); + expect(mock.runAsync).toHaveBeenCalledTimes(3); + }); + + test('does not tick a view that is idle on attach', async () => { + const { view, mock } = createMockView({ activeTicks: 0 }); + attach(view); + await flushFrames(10); + expect(mock.runAsync).not.toHaveBeenCalled(); + }); + + test('wakes when animationActive turns true and sleeps again once settled', async () => { + const { view, timerValues, startAnimation } = createMockView({ activeTicks: 0 }); + attach(view); + startAnimation(2); + await flushFrames(10); + expect(timerValues).toHaveLength(2); + startAnimation(4); + await flushFrames(10); + expect(timerValues).toHaveLength(6); + }); + + test('drives multiple views from one shared frame loop', async () => { + const rafSpy = jest.spyOn(window, 'requestAnimationFrame'); + const a = createMockView({ activeTicks: 3 }); + const b = createMockView({ activeTicks: 3 }); + attach(a.view); + attach(b.view); + // one frame request serves both views + expect(rafSpy).toHaveBeenCalledTimes(1); + await flushFrames(10); + expect(a.timerValues).toHaveLength(3); + expect(b.timerValues).toHaveLength(3); + expect(a.timerValues).toEqual(b.timerValues); + rafSpy.mockRestore(); + }); + + test('uses wall-clock time for the animation timer', async () => { + const { view, timerValues } = createMockView({ activeTicks: 1 }); + const now = Date.now(); + attach(view); + await flushFrames(1); + expect(timerValues[0]).toBeGreaterThanOrEqual(now); + }); + + test('stops ticking after detach', async () => { + const { view, mock, listeners } = createMockView({ activeTicks: 100 }); + const detach = attach(view); + await flushFrames(2); + detach(); + const calls = mock.runAsync.mock.calls.length; + await flushFrames(10); + expect(mock.runAsync).toHaveBeenCalledTimes(calls); + expect(listeners).toHaveLength(0); + }); + + test('returns a no-op detach for views without animation signals', async () => { + const { view, mock } = createMockView({ hasSignals: false }); + const detach = attach(view); + await flushFrames(5); + expect(mock.runAsync).not.toHaveBeenCalled(); + expect(detach).not.toThrow(); + }); + + test('re-attaching the same view replaces its previous registration', async () => { + const { view, listeners } = createMockView({ activeTicks: 1 }); + attach(view); + attach(view); + expect(listeners).toHaveLength(1); + }); + + test('detaches a view whose tick throws (e.g. finalized)', async () => { + const { view, mock, listeners } = createMockView({ activeTicks: 5 }); + attach(view); + mock.signal.mockImplementation(() => { + throw new Error('finalized'); + }); + await flushFrames(3); + expect(listeners).toHaveLength(0); + }); + }); + + describe('frame budget', () => { + test('stops ticking views once the budget is spent and resumes with the skipped ones next frame', async () => { + let clockMs = 0; + jest.spyOn(performance, 'now').mockImplementation(() => clockMs); + const views = [0, 1, 2].map(() => createMockView({ activeTicks: 10 })); + views.forEach(({ view, mock }) => { + attach(view); + const run = mock.runAsync.getMockImplementation(); + mock.runAsync.mockImplementation(async () => { + clockMs += ANIMATION_FRAME_BUDGET_MS; + return run?.() ?? view; + }); + }); + await flushFrames(1); + expect(views.map(({ timerValues }) => timerValues.length)).toEqual([1, 0, 0]); + await flushFrames(1); + expect(views.map(({ timerValues }) => timerValues.length)).toEqual([1, 1, 0]); + await flushFrames(1); + expect(views.map(({ timerValues }) => timerValues.length)).toEqual([1, 1, 1]); + await flushFrames(1); + expect(views.map(({ timerValues }) => timerValues.length)).toEqual([2, 1, 1]); + }); + + test('ticks every view in one frame while under budget', async () => { + const views = [0, 1, 2].map(() => createMockView({ activeTicks: 10 })); + views.forEach(({ view }) => attach(view)); + await flushFrames(1); + expect(views.map(({ timerValues }) => timerValues.length)).toEqual([1, 1, 1]); + }); + }); + + describe('off-screen pausing', () => { + let observerCallback: IntersectionObserverCallback; + const observe = jest.fn(); + const unobserve = jest.fn(); + const disconnect = jest.fn(); + + beforeEach(() => { + observe.mockClear(); + unobserve.mockClear(); + disconnect.mockClear(); + (window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = jest.fn( + (callback: IntersectionObserverCallback) => { + observerCallback = callback; + return { observe, unobserve, disconnect }; + } + ); + }); + + afterEach(() => { + delete (window as unknown as { IntersectionObserver?: unknown }).IntersectionObserver; + }); + + const setVisible = (target: Element, isIntersecting: boolean) => + observerCallback([{ target, isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver); + + test('skips off-screen views and resumes when they become visible', async () => { + const container = document.createElement('div'); + const { view, timerValues } = createMockView({ activeTicks: 3 }); + attach(view, container); + expect(observe).toHaveBeenCalledWith(container); + setVisible(container, false); + await flushFrames(10); + expect(timerValues).toHaveLength(0); + setVisible(container, true); + await flushFrames(10); + expect(timerValues).toHaveLength(3); + }); + + test('unobserves on detach and disconnects when no views remain', () => { + const container = document.createElement('div'); + const { view } = createMockView({ activeTicks: 0 }); + const detach = attach(view, container); + detach(); + expect(unobserve).toHaveBeenCalledWith(container); + expect(disconnect).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts new file mode 100644 index 0000000000..f48bc44747 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts @@ -0,0 +1,202 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Spec, View } from 'vega'; + +import { + ANIMATION_ACTIVE, + ANIMATION_FRAME_BUDGET_MS, + ANIMATION_MIN_FRAME_INTERVAL, + ANIMATION_TIMER, +} from '@spectrum-charts/constants'; + +// Framework-agnostic, page-wide animation scheduler: one frame loop drives every animating chart +// and stops entirely once none are animating. Must not depend on React. + +interface TickerEntry { + view: View; + container: Element; + awake: boolean; + visible: boolean; + running: boolean; + onActiveChange: (name: string, active: boolean) => void; +} + +const entries = new Map(); +const entriesByContainer = new Map(); +let frameHandle: number | undefined; +let frameRunning = false; +let lastFrameTime = -Infinity; +let observer: IntersectionObserver | undefined; + +const requestFrame = (callback: (time: number) => void): number => + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame(callback) + : (setTimeout(() => callback(Date.now()), 16) as unknown as number); + +const cancelFrame = (handle: number): void => { + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle); + else clearTimeout(handle); +}; + +const hasPendingWork = (): boolean => { + for (const entry of entries.values()) { + if (entry.awake && entry.visible) return true; + } + return false; +}; + +const scheduleFrame = (): void => { + if (frameHandle === undefined && !frameRunning && hasPendingWork()) { + frameHandle = requestFrame(frame); + } +}; + +const readActive = (view: View): boolean => { + try { + return Boolean(view.signal(ANIMATION_ACTIVE)); + } catch { + return false; + } +}; + +const clock = (): number => (typeof performance === 'undefined' ? Date.now() : performance.now()); + +const tick = async (entry: TickerEntry, now: number): Promise => { + const { view } = entry; + entry.running = true; + try { + await view.signal(ANIMATION_TIMER, now).runAsync(); + } catch { + entry.running = false; + detach(entry); + return; + } + entry.running = false; + if (entries.get(view) !== entry) return; + // only sleep once the spec reports every animation (including its final grace tick) has settled + if (!readActive(view)) entry.awake = false; +}; + +const runFrame = async (now: number, frameStart: number): Promise => { + const due = [...entries.values()].filter((entry) => entry.awake && entry.visible && !entry.running); + for (const entry of due) { + await tick(entry, now); + // ticked charts move to the back so charts skipped by the budget go first next frame + if (entries.get(entry.view) === entry) { + entries.delete(entry.view); + entries.set(entry.view, entry); + } + if (clock() - frameStart >= ANIMATION_FRAME_BUDGET_MS) break; + } +}; + +function frame(time: number): void { + frameHandle = undefined; + // time < lastFrameTime means the frame clock was reset (e.g. a new document timeline) + if (time < lastFrameTime || time - lastFrameTime >= ANIMATION_MIN_FRAME_INTERVAL) { + lastFrameTime = time; + frameRunning = true; + // wall-clock time to match the spec's now()-based animation timestamps + runFrame(Date.now(), clock()).finally(() => { + frameRunning = false; + scheduleFrame(); + }); + return; + } + scheduleFrame(); +} + +const wake = (entry: TickerEntry): void => { + entry.awake = true; + scheduleFrame(); +}; + +const getObserver = (): IntersectionObserver | undefined => { + if (observer || typeof IntersectionObserver === 'undefined') return observer; + observer = new IntersectionObserver((observed) => { + for (const { target, isIntersecting } of observed) { + const entry = entriesByContainer.get(target); + if (entry) entry.visible = isIntersecting; + } + scheduleFrame(); + }); + return observer; +}; + +function detach(entry: TickerEntry): void { + if (entries.get(entry.view) !== entry) return; + entries.delete(entry.view); + entriesByContainer.delete(entry.container); + entry.view.removeSignalListener(ANIMATION_ACTIVE, entry.onActiveChange); + observer?.unobserve(entry.container); + if (!entries.size) { + observer?.disconnect(); + observer = undefined; + } + if (frameHandle !== undefined && !hasPendingWork()) { + cancelFrame(frameHandle); + frameHandle = undefined; + } +} + +/** + * Checks whether a spec contains the shared animation timer that the ticker drives. + * @param spec - vega spec + * @returns boolean + */ +export const isAnimatedSpec = (spec: Spec): boolean => + Boolean(spec.signals?.some((signal) => signal.name === ANIMATION_TIMER)); + +/** + * Removes Vega's always-on timer event from the animation timer signal so the animation ticker is its only clock. + * @param spec - vega spec, mutated in place + */ +export const removeAnimationTimerEvents = (spec: Spec): void => { + const timer = spec.signals?.find((signal) => signal.name === ANIMATION_TIMER); + if (timer && 'on' in timer) delete timer.on; +}; + +/** + * Registers a view with the shared animation ticker; its spec should have had `removeAnimationTimerEvents` applied. + * @param view - vega view whose spec contains the animation timer and animationActive signals + * @param container - element the view renders into, used to pause ticking while off-screen + * @returns function that detaches the view from the ticker + */ +export const attachAnimationTicker = (view: View, container: Element): (() => void) => { + const existing = entries.get(view); + if (existing) detach(existing); + + const entry: TickerEntry = { + view, + container, + awake: false, + visible: true, + running: false, + onActiveChange: (_name, active) => { + if (active) wake(entry); + }, + }; + try { + view.addSignalListener(ANIMATION_ACTIVE, entry.onActiveChange); + } catch { + // spec has no animations to drive + return () => {}; + } + entries.set(view, entry); + const previous = entriesByContainer.get(container); + if (previous) detach(previous); + entriesByContainer.set(container, entry); + getObserver()?.observe(container); + if (readActive(view)) wake(entry); + + return () => detach(entry); +}; diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts index ba6411ce8f..41e55be684 100644 --- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts @@ -12,6 +12,7 @@ import { Data } from 'vega'; import { + ANIMATION_TIMER, BACKGROUND_COLOR, CHART_SIZE_POINT_SIZE, COLOR_SCALE, @@ -21,12 +22,12 @@ import { DEFAULT_STROKE_WIDTH_RULE, DEFAULT_TIME_DIMENSION, DEFAULT_TRANSFORMED_TIME_DIMENSION, + DRAW_IN_CLOCK_DATA, FILTERED_TABLE, GROUP_ID, HOVERED_ITEM, HOVER_ANIM_LAST_CHANGE_DATA, HOVER_TARGETS, - ANIMATION_TIMER, LINEAR_PADDING, MARK_ID, SERIES_ID, @@ -762,6 +763,11 @@ describe('lineSpecBuilder', () => { expect(resultData.find((d) => d.name === 'line0_drawInLerp')).toBeDefined(); }); + test('adds the draw-in clock data so the animation timer can stop when draw-in finishes', () => { + const resultData = addData(baseData, { ...defaultLineOptions, isDrawInAnimate: true, scaleType: 'time' }); + expect(resultData.find((d) => d.name === DRAW_IN_CLOCK_DATA)).toBeDefined(); + }); + test('for a linear scale, adds the lead transform on filteredTable without the ms-formula transform', () => { const resultData = addData(baseData, { ...defaultLineOptions, isDrawInAnimate: true, scaleType: 'linear' }); const tableData = resultData.find((d) => d.name === TABLE); diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts index 4c542a9200..5fe550d2a6 100644 --- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts @@ -90,7 +90,7 @@ import { import { getLinePointAnnotationMarks } from './linePointAnnotation'; import { getLineStaticPoint, getLineStaticPointBackground } from './linePointUtils'; import { getPopoverMarkName, isDualMetricAxis } from './lineUtils'; -import { addLineDrawInAnimationSignals, addLineDrawInLeadTransform, addLineDrawInTimeMsTransform, getLineDrawInData, getLineDrawInPointIndexData } from '../marks/drawInAnimationUtils'; +import { addDrawInClockData, addLineDrawInAnimationSignals, addLineDrawInLeadTransform, addLineDrawInTimeMsTransform, getLineDrawInData, getLineDrawInPointIndexData } from '../marks/drawInAnimationUtils'; export const addLine = produce< ScSpec, @@ -279,6 +279,7 @@ export const addData = produce((data, options) => { addLineHoverData(data, options); if (options.isDrawInAnimate) { + addDrawInClockData(data); if (scaleType === 'point') { const pointIndexData = getLineDrawInPointIndexData(options); addLineDrawInLeadTransform(pointIndexData, options); diff --git a/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts new file mode 100644 index 0000000000..0ad688b65b --- /dev/null +++ b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { EventStream, Signal } from 'vega'; + +import { ANIMATION_ACTIVE, ANIMATION_THROTTLE, ANIMATION_TIMER } from '@spectrum-charts/constants'; + +import { addAnimationTimerSignal } from './animationTimerUtils'; + +const getTimerFilter = (signals: Signal[]) => + (signals.find((s) => s.name === ANIMATION_TIMER)?.on?.[0].events as EventStream).filter; +const getActiveUpdate = (signals: Signal[]) => + (signals.find((s) => s.name === ANIMATION_ACTIVE) as { update?: string } | undefined)?.update; +const condition = (name: string) => (clock: string) => `${name}(${clock})`; + +describe('addAnimationTimerSignal()', () => { + test('adds the timer filtered by the now() condition and animationActive using the timer as its clock', () => { + const signals: Signal[] = []; + addAnimationTimerSignal(signals, condition('a')); + expect(signals).toStrictEqual([ + { + name: ANIMATION_TIMER, + value: 0, + on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE, filter: '(a(now()))' }, update: 'now()' }], + }, + { name: ANIMATION_ACTIVE, value: true, update: `(a(${ANIMATION_TIMER}))` }, + ]); + }); + + test('ORs additional conditions into the existing timer filter and animationActive', () => { + const signals: Signal[] = []; + addAnimationTimerSignal(signals, condition('a')); + addAnimationTimerSignal(signals, condition('b')); + expect(signals).toHaveLength(2); + expect(getTimerFilter(signals)).toEqual('(a(now())) || (b(now()))'); + expect(getActiveUpdate(signals)).toEqual(`(a(${ANIMATION_TIMER})) || (b(${ANIMATION_TIMER}))`); + }); + + test('does not duplicate a condition that is already present', () => { + const signals: Signal[] = []; + addAnimationTimerSignal(signals, condition('a')); + addAnimationTimerSignal(signals, condition('a')); + expect(getTimerFilter(signals)).toEqual('(a(now()))'); + expect(getActiveUpdate(signals)).toEqual(`(a(${ANIMATION_TIMER}))`); + }); + + test('leaves an existing timer without a string filter untouched', () => { + const signals: Signal[] = [ + { name: ANIMATION_TIMER, value: 0, on: [{ events: { type: 'timer' }, update: 'now()' }] }, + ]; + addAnimationTimerSignal(signals, condition('a')); + expect(getTimerFilter(signals)).toBeUndefined(); + expect(getActiveUpdate(signals)).toEqual(`(a(${ANIMATION_TIMER}))`); + }); +}); diff --git a/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts new file mode 100644 index 0000000000..3a6184cf90 --- /dev/null +++ b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { EventStream, Signal } from 'vega'; + +import { ANIMATION_ACTIVE, ANIMATION_THROTTLE, ANIMATION_TIMER } from '@spectrum-charts/constants'; + +/** Builds a data-only vega expression that is true while an animation still needs ticks, given a clock expression. */ +export type AnimationActiveCondition = (clock: string) => string; + +/** + * Appends `condition` to an OR-chain expression unless it is already present. + * @param expr - the existing expression + * @param condition - the parenthesized condition to OR in + * @returns string + */ +const orCondition = (expr: string, condition: string): string => + expr.includes(condition) ? expr : `${expr} || ${condition}`; + +/** + * Adds the shared animation timer and `animationActive` signals (if missing) and ORs `getCondition` into both. + * @param signals - the signals array to add the animation timer to + * @param getCondition - builds the active condition; event filters cannot read signals, so it may only reference data + */ +export const addAnimationTimerSignal = (signals: Signal[], getCondition: AnimationActiveCondition): void => { + const filterCondition = `(${getCondition('now()')})`; + const activeCondition = `(${getCondition(ANIMATION_TIMER)})`; + + const timer = signals.find((signal) => signal.name === ANIMATION_TIMER); + if (!timer) { + signals.push({ + name: ANIMATION_TIMER, + value: 0, + on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE, filter: filterCondition }, update: 'now()' }], + }); + } else { + const events = timer.on?.[0]?.events as EventStream | undefined; + if (events && typeof events.filter === 'string') { + events.filter = orCondition(events.filter, filterCondition); + } + } + + const active = signals.find((signal) => signal.name === ANIMATION_ACTIVE); + if (!active) { + signals.push({ name: ANIMATION_ACTIVE, value: true, update: activeCondition }); + } else if ('update' in active && typeof active.update === 'string') { + active.update = orCondition(active.update, activeCondition); + } +}; diff --git a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts index 3b5fa1d3b1..705b4d01db 100644 --- a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts @@ -12,10 +12,12 @@ import { Data, Signal, SourceData, Transforms } from 'vega'; import { + ANIMATION_ACTIVE, ANIMATION_THROTTLE, ANIMATION_TIMER, DEFAULT_TRANSFORMED_TIME_DIMENSION, DRAW_IN_ANIMATION_DURATION_MS, + DRAW_IN_CLOCK_DATA, FILTERED_TABLE, LAST_RSC_SERIES_ID, SERIES_ID, @@ -24,6 +26,7 @@ import { import { defaultLineMarkOptions, defaultLineOptions } from '../line/lineTestUtils'; import { LineSpecOptions } from '../types'; import { + addDrawInClockData, addDrawInClockSignals, addLineDrawInAnimationSignals, addLineDrawInLeadTransform, @@ -70,7 +73,12 @@ describe('getLineDrawInDataSourceName()', () => { describe('getLineDrawInPointIndexData()', () => { test('builds a formula source indexing each row within the x scale domain', () => { - const options: LineSpecOptions = { ...defaultLineOptions, name: 'line0', dimension: 'category', scaleType: 'point' }; + const options: LineSpecOptions = { + ...defaultLineOptions, + name: 'line0', + dimension: 'category', + scaleType: 'point', + }; expect(getLineDrawInPointIndexData(options)).toStrictEqual({ name: 'line0_drawInIndexed', source: FILTERED_TABLE, @@ -104,7 +112,13 @@ describe('addLineDrawInTimeMsTransform()', () => { describe('addLineDrawInLeadTransform()', () => { test('adds a lead window transform keyed to the sort field for a time scale', () => { const sourceData: Data = { name: 'filteredTable' }; - const options: LineSpecOptions = { ...defaultLineOptions, name: 'line0', dimension: 'datetime', metric: 'value', scaleType: 'time' }; + const options: LineSpecOptions = { + ...defaultLineOptions, + name: 'line0', + dimension: 'datetime', + metric: 'value', + scaleType: 'time', + }; addLineDrawInLeadTransform(sourceData, options); expect(sourceData.transform).toStrictEqual([ { @@ -173,8 +187,7 @@ describe('getLineDrawInData()', () => { transform: [ { type: 'filter', - expr: - 'datum.rscDrawInTimeMs <= line0_drawInAnimCutoff && isValid(datum.line0_drawInNextDimValue) && datum.line0_drawInNextDimValue > line0_drawInAnimCutoff', + expr: 'datum.rscDrawInTimeMs <= line0_drawInAnimCutoff && isValid(datum.line0_drawInNextDimValue) && datum.line0_drawInNextDimValue > line0_drawInAnimCutoff', }, { type: 'formula', as: 'isDrawInTip', expr: 'true' }, ], @@ -187,7 +200,12 @@ describe('getLineDrawInData()', () => { }); test('reads from the name-scoped indexed source for a point scale', () => { - const options: LineSpecOptions = { ...defaultLineOptions, name: 'line0', dimension: 'category', scaleType: 'point' }; + const options: LineSpecOptions = { + ...defaultLineOptions, + name: 'line0', + dimension: 'category', + scaleType: 'point', + }; const [prevData, tipData] = getLineDrawInData(options) as SourceData[]; expect(prevData.source).toEqual('line0_drawInIndexed'); expect(tipData.source).toEqual('line0_drawInIndexed'); @@ -197,6 +215,23 @@ describe('getLineDrawInData()', () => { }); }); +describe('addDrawInClockData()', () => { + test('adds a single done-flag data source triggered by drawInAnimT', () => { + const data: Data[] = []; + addDrawInClockData(data); + addDrawInClockData(data); + expect(data).toStrictEqual([ + { + name: DRAW_IN_CLOCK_DATA, + values: [{ done: false }], + on: [ + { trigger: 'drawInAnimT', modify: `data('${DRAW_IN_CLOCK_DATA}')[0]`, values: '{done: drawInAnimT >= 1}' }, + ], + }, + ]); + }); +}); + describe('addDrawInClockSignals()', () => { test('adds the shared mount-timer chain', () => { const signals: Signal[] = []; @@ -205,9 +240,19 @@ describe('addDrawInClockSignals()', () => { { name: ANIMATION_TIMER, value: 0, - on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE }, update: 'now()' }], + on: [ + { + events: { type: 'timer', throttle: ANIMATION_THROTTLE, filter: `(!data('${DRAW_IN_CLOCK_DATA}')[0].done)` }, + update: 'now()', + }, + ], + }, + { name: ANIMATION_ACTIVE, value: true, update: `(!data('${DRAW_IN_CLOCK_DATA}')[0].done)` }, + { + name: 'drawInStart', + value: 0, + on: [{ events: { signal: ANIMATION_TIMER }, update: 'drawInStart || animationTimer' }], }, - { name: 'drawInStart', init: 'now()' }, { name: 'drawInAnimT', value: 0, @@ -224,7 +269,7 @@ describe('addDrawInClockSignals()', () => { const signals: Signal[] = []; addDrawInClockSignals(signals); addDrawInClockSignals(signals); - expect(signals).toHaveLength(4); + expect(signals).toHaveLength(5); }); test('eases 0 -> 0, 1 -> 1, and the midpoint -> 0.5', () => { @@ -248,6 +293,7 @@ describe('addLineDrawInAnimationSignals()', () => { addLineDrawInAnimationSignals(signals, options); expect(signals.map((s) => s.name)).toEqual([ ANIMATION_TIMER, + ANIMATION_ACTIVE, 'drawInStart', 'drawInAnimT', 'drawInAnimTEased', @@ -308,7 +354,12 @@ describe('getDualAxisDrawInRule()', () => { describe('getLineDrawInXEncoding()', () => { test('lerps the flagged tip point toward its lead position for a time scale', () => { - const result = getLineDrawInXEncoding({ ...defaultLineMarkOptions, name: 'line0', dimension: 'datetime', scaleType: 'time' }); + const result = getLineDrawInXEncoding({ + ...defaultLineMarkOptions, + name: 'line0', + dimension: 'datetime', + scaleType: 'time', + }); const currentPos = `scale('xTime', datum.${DEFAULT_TRANSFORMED_TIME_DIMENSION})`; const nextPos = `scale('xTime', datum.line0_drawInNextDimValue)`; const tween = @@ -319,7 +370,12 @@ describe('getLineDrawInXEncoding()', () => { }); test('looks up the lead position via the real category value for a point scale', () => { - const result = getLineDrawInXEncoding({ ...defaultLineMarkOptions, name: 'line0', dimension: 'category', scaleType: 'point' }); + const result = getLineDrawInXEncoding({ + ...defaultLineMarkOptions, + name: 'line0', + dimension: 'category', + scaleType: 'point', + }); const currentPos = `scale('xPoint', datum.category)`; const nextPos = `scale('xPoint', datum.line0_drawInNextCategoryValue)`; const tween = @@ -332,7 +388,12 @@ describe('getLineDrawInXEncoding()', () => { describe('getLineDrawInYEncoding()', () => { test('returns a single production rule against yLinear when there is no dual metric axis', () => { - const result = getLineDrawInYEncoding({ ...defaultLineMarkOptions, name: 'line0', metric: 'value', scaleType: 'time' }); + const result = getLineDrawInYEncoding({ + ...defaultLineMarkOptions, + name: 'line0', + metric: 'value', + scaleType: 'time', + }); const currentPos = `scale('yLinear', datum.value)`; const nextPos = `scale('yLinear', datum.line0_drawInNextMetricValue)`; expect(result).toStrictEqual({ diff --git a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts index 14d7f7f49c..9650c365e1 100644 --- a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts +++ b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts @@ -13,13 +13,13 @@ import { produce } from 'immer'; import { Data, NumericValueRef, ProductionRule, Signal, SourceData, Transforms } from 'vega'; import { - ANIMATION_THROTTLE, ANIMATION_TIMER, DEFAULT_TRANSFORMED_TIME_DIMENSION, DRAW_IN_ANIM_CUTOFF, DRAW_IN_ANIM_T, DRAW_IN_ANIM_T_EASED, DRAW_IN_ANIMATION_DURATION_MS, + DRAW_IN_CLOCK_DATA, DRAW_IN_DOMAIN_MAX, DRAW_IN_DOMAIN_MIN, DRAW_IN_LERP_DATA, @@ -44,6 +44,7 @@ import { getScaleName } from '../scale/scaleSpecBuilder'; import { getDualAxisScaleNames } from '../scale/scaleUtils'; import { hasSignalByName } from '../signal/signalSpecBuilder'; import { LineSpecOptions, ScaleType } from '../types'; +import { addAnimationTimerSignal } from './animationTimerUtils'; /** * The field used to compare rows against the animation cutoff. Time scales need a numeric-ms field @@ -177,24 +178,42 @@ export const getLineDrawInData = (options: LineSpecOptions): Data[] => { return [prevData, tipData, lerpData]; }; +/** + * Adds the single-row data source that records when draw-in finishes so the animation timer can stop ticking. + * @param data - the data array to add the draw-in clock data to + */ +export const addDrawInClockData = (data: Data[]): void => { + if (data.some((d) => d.name === DRAW_IN_CLOCK_DATA)) return; + data.push({ + name: DRAW_IN_CLOCK_DATA, + values: [{ done: false }], + on: [{ trigger: DRAW_IN_ANIM_T, modify: `data('${DRAW_IN_CLOCK_DATA}')[0]`, values: `{done: ${DRAW_IN_ANIM_T} >= 1}` }], + }); +}; + +/** + * Gets the data-only condition that is true until the draw-in animation has finished. + * @returns string + */ +export const getDrawInAnimationActiveCondition = (): string => `!data('${DRAW_IN_CLOCK_DATA}')[0].done`; + /** * Adds the shared mount-timer chain (`drawInStart` -> `drawInAnimT` -> `drawInAnimTEased`) every * draw-in animated mark reads from. - * `drawInStart` - captures the wall clock time once at the start of the animation + * `drawInStart` - captures the animation timer's first tick * `drawInAnimT` - the animation progess (t) from 0-1 * `drawInAnimTEased` - the animation progress with easing applied. Current easing formula is in-out quadratic */ export const addDrawInClockSignals = (signals: Signal[]): void => { - if (!hasSignalByName(signals, ANIMATION_TIMER)) { + addAnimationTimerSignal(signals, getDrawInAnimationActiveCondition); + if (!hasSignalByName(signals, DRAW_IN_START)) { + // starts on the first timer tick (the first painted frame) so a slow mount doesn't consume the animation signals.push({ - name: ANIMATION_TIMER, + name: DRAW_IN_START, value: 0, - on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE }, update: 'now()' }], + on: [{ events: { signal: ANIMATION_TIMER }, update: `${DRAW_IN_START} || ${ANIMATION_TIMER}` }], }); } - if (!hasSignalByName(signals, DRAW_IN_START)) { - signals.push({ name: DRAW_IN_START, init: 'now()' }); - } if (!hasSignalByName(signals, DRAW_IN_ANIM_T)) { signals.push({ name: DRAW_IN_ANIM_T, diff --git a/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.test.ts b/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.test.ts index 64cb769fc2..7505b32d08 100644 --- a/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.test.ts @@ -12,6 +12,7 @@ import { Data, Signal, ValuesData } from 'vega'; import { + ANIMATION_ACTIVE, ANIMATION_HOVER_SPEED, ANIMATION_THROTTLE, HOVER_ACTIVE_TIMER, @@ -36,6 +37,7 @@ import { getHoverFractionSignal, getHoverSeriesFractionData, getHoverTargetData, + getHoverAnimationActiveCondition, } from './hoverAnimationUtils'; describe('getHoverTargetData()', () => { @@ -240,8 +242,14 @@ describe('addHoverAnimationSignals()', () => { { name: ANIMATION_TIMER, value: 0, - on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE }, update: 'now()' }], + on: [ + { + events: { type: 'timer', throttle: ANIMATION_THROTTLE, filter: `(${getHoverAnimationActiveCondition('now()')})` }, + update: 'now()', + }, + ], }, + { name: ANIMATION_ACTIVE, value: true, update: `(${getHoverAnimationActiveCondition(ANIMATION_TIMER)})` }, { name: HOVER_ANIMATING, value: false, @@ -277,6 +285,7 @@ describe('addHoverAnimationSignals()', () => { expect(signals.filter((s) => s.name === HOVER_ACTIVE_TIMER)).toHaveLength(1); expect(signals.map((s) => s.name)).toEqual([ ANIMATION_TIMER, + ANIMATION_ACTIVE, HOVER_ANIMATING, HOVER_IDLE_TICKS, HOVER_ACTIVE_TIMER, @@ -353,6 +362,21 @@ describe('addHoverAnimationSignals()', () => { }); }); +describe('getHoverAnimationActiveCondition()', () => { + const evalCondition = (row: { lastChange: number; settled: boolean }, now: number): boolean => + // eslint-disable-next-line no-new-func + new Function('data', 'clock', `return ${getHoverAnimationActiveCondition('clock')};`)(() => [row], now); + + test('stays active until the grace tick has settled the animation', () => { + expect(evalCondition({ lastChange: 0, settled: false }, 10000)).toBe(true); + expect(evalCondition({ lastChange: 0, settled: true }, 10000)).toBe(false); + }); + + test('stays active within the nominal duration of the last change even if marked settled', () => { + expect(evalCondition({ lastChange: 1000, settled: true }, 1000 + ANIMATION_HOVER_SPEED)).toBe(true); + }); +}); + describe('addHoverAnimLastChangeData()', () => { test('creates the shared tracker data source, seeded at rest, with an on-trigger for the mark', () => { const data: Data[] = []; @@ -360,12 +384,17 @@ describe('addHoverAnimLastChangeData()', () => { expect(data).toStrictEqual([ { name: HOVER_ANIM_LAST_CHANGE_DATA, - values: [{ lastChange: 0 }], + values: [{ lastChange: 0, settled: false }], on: [ + { + trigger: HOVER_IDLE_TICKS, + modify: `data('${HOVER_ANIM_LAST_CHANGE_DATA}')[0]`, + values: `{settled: ${HOVER_IDLE_TICKS} >= 2}`, + }, { trigger: `line0_${HOVER_TARGETS}`, modify: `data('${HOVER_ANIM_LAST_CHANGE_DATA}')[0]`, - values: '{lastChange: now()}', + values: '{lastChange: now(), settled: false}', }, ], }, @@ -378,9 +407,9 @@ describe('addHoverAnimLastChangeData()', () => { addHoverAnimLastChangeData(data, 'line1'); expect(data.filter((d) => d.name === HOVER_ANIM_LAST_CHANGE_DATA)).toHaveLength(1); const [tracker] = data as ValuesData[]; - expect(tracker.on).toHaveLength(2); - expect(tracker.on?.[0].trigger).toEqual(`line0_${HOVER_TARGETS}`); - expect(tracker.on?.[1].trigger).toEqual(`line1_${HOVER_TARGETS}`); + expect(tracker.on).toHaveLength(3); + expect(tracker.on?.[1].trigger).toEqual(`line0_${HOVER_TARGETS}`); + expect(tracker.on?.[2].trigger).toEqual(`line1_${HOVER_TARGETS}`); }); test('initializes `on` when an existing entry was constructed without one', () => { diff --git a/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.ts b/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.ts index 1dbba214fb..a1e82efa59 100644 --- a/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.ts +++ b/packages/vega-spec-builder-s2/src/marks/hoverAnimationUtils.ts @@ -30,6 +30,7 @@ import { } from '@spectrum-charts/constants'; import { hasSignalByName } from '../signal/signalSpecBuilder'; +import { addAnimationTimerSignal } from './animationTimerUtils'; /** One hover condition. expr must evaluate to 1 | 0 | null. */ export interface HoverMatchRule { @@ -182,7 +183,18 @@ export const getHoverSeriesFractionData = (name: string, keyField: string = SERI export const addHoverAnimLastChangeData = (data: Data[], name: string): void => { let lastChangeData = data.find((d) => d.name === HOVER_ANIM_LAST_CHANGE_DATA) as ValuesData | undefined; if (!lastChangeData) { - lastChangeData = { name: HOVER_ANIM_LAST_CHANGE_DATA, values: [{ lastChange: 0 }], on: [] }; + lastChangeData = { + name: HOVER_ANIM_LAST_CHANGE_DATA, + values: [{ lastChange: 0, settled: false }], + // settled lets the animation timer's event filter (which can't read signals) stop ticking once the grace tick has run + on: [ + { + trigger: HOVER_IDLE_TICKS, + modify: `data('${HOVER_ANIM_LAST_CHANGE_DATA}')[0]`, + values: `{settled: ${HOVER_IDLE_TICKS} >= 2}`, + }, + ], + }; data.push(lastChangeData); } if (lastChangeData.on === undefined) { @@ -191,7 +203,7 @@ export const addHoverAnimLastChangeData = (data: Data[], name: string): void => lastChangeData.on.push({ trigger: `${name}_${HOVER_TARGETS}`, modify: `data('${HOVER_ANIM_LAST_CHANGE_DATA}')[0]`, - values: '{lastChange: now()}', + values: '{lastChange: now(), settled: false}', }); }; @@ -230,19 +242,23 @@ export const getEmphasisRamp = (fractionExpr: string): string => // Scales the fraction by 1/HOVER_NEUTRAL_TARGET and shifts left by 1. The whole expression is then clamped to 0..1. `clamp((${fractionExpr} - ${HOVER_NEUTRAL_TARGET}) / (1 - ${HOVER_NEUTRAL_TARGET}), 0, 1)`; +/** + * Gets the data-only condition that is true while a hover animation (or its grace tick) is pending. + * @param clock - vega expression for the current time + * @returns string + */ +export const getHoverAnimationActiveCondition = (clock: string): string => { + const lastChangeRow = `data('${HOVER_ANIM_LAST_CHANGE_DATA}')[0]`; + return `!${lastChangeRow}.settled || ${clock} - ${lastChangeRow}.lastChange < ${ANIMATION_HOVER_SPEED + ANIMATION_THROTTLE}`; +}; + /** * Adds the hover animation signals to the signals array. * @param signals - the signals array to add the hover animation signals to * @param name - the name of the mark */ export const addHoverAnimationSignals = (signals: Signal[], name: string): void => { - if (!hasSignalByName(signals, ANIMATION_TIMER)) { - signals.push({ - name: ANIMATION_TIMER, - value: 0, - on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE }, update: 'now()' }], - }); - } + addAnimationTimerSignal(signals, getHoverAnimationActiveCondition); if (!hasSignalByName(signals, HOVER_ANIMATING)) { signals.push({ name: HOVER_ANIMATING, diff --git a/planning/research/hover-animation-system/hover-animation-system.md b/planning/research/hover-animation-system/hover-animation-system.md index c452dfc3b5..4b5e7992f4 100644 --- a/planning/research/hover-animation-system/hover-animation-system.md +++ b/planning/research/hover-animation-system/hover-animation-system.md @@ -242,6 +242,56 @@ idle long enough to stop." What idle gating removes is the expensive part downst `lerp(...)` recompute across every series of every animated mark — which is where the real CPU cost scales with chart size. For a chart with many series/marks, that's the win; for a one-series chart it's a wash. +> **Superseded for embedded charts by §3g.** The spec-level gate can't stop the ticks themselves: vega-view +> schedules a `runAsync` for every timer event *before* the event-stream filter runs. With `autosize.resize: +> true`, every run also triggers a re-layout (`resizeView` → `runAfter(v => v.resize())`). The result was +> about 30 runs/s per chart, forever. §3g removes the Vega timer when the chart is embedded. + +### 3g. On-demand animation ticker (embed-time) + +The spec still carries the timer (`animationTimer`, throttle 33), so a standalone Vega consumer keeps working. +The spec also emits an `animationActive` signal: the OR of each animation's "still moving" condition. +- Hover: `!hoverAnimLastChangeData[0].settled || clock - lastChange < SPEED + THROTTLE`. `settled` is set + by `hoverIdleTicks >= 2`. +- Draw-in: `!drawInClockData[0].done`. `done` is set by `drawInAnimT >= 1`. + +Both are built by `addAnimationTimerSignal` (`vega-spec-builder-s2/src/marks/animationTimerUtils.ts`). It +also ORs the same conditions into the timer event's `filter`. The filter can only use `data()`, not signals. + +At embed time (`react-spectrum-charts-s2/src/animation/animationTicker.ts`, framework-agnostic): +1. `removeAnimationTimerEvents(spec)` strips the timer's `on` handler. It is not blocked through + `config.events.timer=false`, because that logs a "Blocked timer" warning for every chart. +2. `attachAnimationTicker(view, container)` registers the view with one page-wide rAF loop. + - Each frame, every view that is awake and visible gets `view.signal('animationTimer', Date.now()).runAsync()`. + It must be `Date.now()` because the spec's `now()` timestamps are wall-clock. + - A view goes to sleep when `animationActive` is false after a run. A signal listener on + `animationActive` wakes it. + - The loop stops when no view is awake and visible. + - Off-screen charts are paused by a shared IntersectionObserver. Progress is wall-clock, so an + animation that runs off-screen shows its end state when the chart scrolls into view. + - `ANIMATION_MIN_FRAME_INTERVAL` (constants): `0` means native refresh rate. Set it to + `ANIMATION_THROTTLE` for about 30fps. + +Measured on a dashboard of 20 charts × 30 series: + +| Build | Idle script ms/s | Idle task ms/s | Idle DOM updates/s | +|---|---|---|---| +| 33ms timer | 25 | 116 | 38 | +| Ticker, native rate | 2 | 18 | 0 | + +The idle numbers are at 4x CPU throttle. Hover animation now runs at the native refresh rate. + +**Frame budget.** Each frame ticks views one at a time and awaits each `runAsync`. It stops once +`ANIMATION_FRAME_BUDGET_MS` (8ms) has been used, and it always ticks at least one view. A view that +ticks moves to the back of the queue, so views skipped by the budget go first next frame. Under load, +charts update less often, but frames don't stall. Progress is wall-clock, so animation duration stays +the same. + +**Draw-in clock starts on the first tick (B1).** `drawInStart` is `0` until the first `animationTimer` +tick. It then latches that tick's value with `drawInStart || animationTimer`. On a dashboard that takes +about 1.6s to mount (4x CPU), the first painted frame used to show the draw-in 92–99% done. It now +shows about 0%. + **Coupling risk — read before wiring this in:** `hoverAnimating`'s `update` expression references `data('hoverAnimLastChangeData')[0]` by name. If `addHoverAnimationSignals` is called for a mark but `addHoverAnimLastChangeData` is never called for *any* mark on that chart, `hoverAnimLastChangeData` won't diff --git a/scripts/perf/animationBenchmark.mjs b/scripts/perf/animationBenchmark.mjs new file mode 100644 index 0000000000..8e47f87cbb --- /dev/null +++ b/scripts/perf/animationBenchmark.mjs @@ -0,0 +1,300 @@ +#!/usr/bin/env node +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Animation performance benchmark against a running S2 Storybook (`yarn storybook:s2`, packages built). + * Requires the Playwright Chromium browser (`yarn playwright install chromium`). + * + * Scenarios: + * drawIn records from the first chart mounting until the draw-in animation has finished + * hover sweeps the mouse across every visible chart + * idle records with no interaction (should be ~0 DOM updates/s) + * + * Measure: + * yarn perf:animation --preset line-hover --label before + * yarn perf:animation --preset line-draw-in --label after --cpu 1 --runs 5 + * yarn perf:animation --story --args "chartCount:5" --scenarios hover,idle --label custom + * Compare saved results: + * yarn perf:animation --compare perf-results/line-hover-before.json perf-results/line-hover-after.json + * List presets: + * yarn perf:animation --list + * + * To benchmark a new mark, add a performance story and a preset entry to PRESETS. + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { parseArgs } from 'node:util'; + +import { chromium } from 'playwright'; + +const PRESETS = { + 'line-hover': { + story: 'react-spectrum-charts-2-line-features-hoveranimation-performance--dashboard', + args: 'chartCount:20;seriesPerChart:30', + scenarios: ['hover', 'idle'], + }, + 'line-draw-in': { + story: 'react-spectrum-charts-2-line-features-drawinanimation-performance--dashboard', + args: 'chartCount:20;seriesPerChart:10;pointsPerSeries:10', + scenarios: ['drawIn', 'idle'], + }, +}; + +const SCENARIOS = ['drawIn', 'hover', 'idle']; +/** Draw-in animation length plus slack for the last charts to mount and finish. */ +const DRAW_IN_WINDOW_MS = 1500; +const MOUSE_STEP_MS = 16; +const VIEWPORT = { width: 1500, height: 1100 }; +const CHART_SELECTOR = 'svg.marks'; + +const OPTIONS = { + preset: { type: 'string' }, + story: { type: 'string' }, + args: { type: 'string' }, + scenarios: { type: 'string' }, + label: { type: 'string' }, + url: { type: 'string', default: 'http://localhost:6010' }, + runs: { type: 'string', default: '3' }, + cpu: { type: 'string', default: '4' }, + duration: { type: 'string', default: '5000' }, + out: { type: 'string' }, + headed: { type: 'boolean', default: false }, + compare: { type: 'boolean', default: false }, + list: { type: 'boolean', default: false }, +}; + +/** Merges CLI flags over the chosen preset into a validated config. */ +const getConfig = () => { + const { values, positionals } = parseArgs({ options: OPTIONS, allowPositionals: true }); + if (values.list || values.compare) return { ...values, files: positionals }; + + const preset = values.preset ? PRESETS[values.preset] : {}; + if (!preset) throw new Error(`Unknown preset "${values.preset}". Run with --list to see presets.`); + const story = values.story ?? preset.story; + if (!story) throw new Error('Pass --preset or --story.'); + if (!values.label) throw new Error('--label is required (e.g. --label before) so results can be compared.'); + + const scenarios = values.scenarios?.split(',') ?? preset.scenarios ?? ['hover', 'idle']; + const unknown = scenarios.filter((s) => !SCENARIOS.includes(s)); + if (unknown.length) throw new Error(`Unknown scenario(s): ${unknown.join(', ')}. Use ${SCENARIOS.join(', ')}.`); + + const name = values.preset ?? 'custom'; + return { + name, + label: values.label, + story, + args: values.args ?? preset.args ?? '', + scenarios, + url: values.url, + runs: Number(values.runs), + cpu: Number(values.cpu), + duration: Number(values.duration), + headed: values.headed, + out: values.out ?? `perf-results/${name}-${values.label}.json`, + }; +}; + +const percentile = (values, p) => { + if (!values.length) return NaN; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; +}; +const median = (values) => percentile(values, 50); + +/** Runs in the page before any app code: collects rAF frame deltas and DOM mutation counts while recording. */ +const instrumentPage = () => { + const perf = { recording: false, frames: [], updates: 0 }; + window.__perf = perf; + let last = performance.now(); + const onFrame = (now) => { + if (perf.recording) perf.frames.push(now - last); + last = now; + requestAnimationFrame(onFrame); + }; + requestAnimationFrame(onFrame); + new MutationObserver(() => { + if (perf.recording) perf.updates++; + }).observe(document, { subtree: true, attributes: true, childList: true }); +}; + +const getCpuTime = async (cdp) => { + const { metrics } = await cdp.send('Performance.getMetrics'); + const get = (name) => metrics.find((m) => m.name === name)?.value ?? 0; + return { script: get('ScriptDuration'), task: get('TaskDuration') }; +}; + +/** Stops recording and converts the raw page data and CPU deltas into per-second stats. */ +const finishRecording = async (page, cdp, cpuBefore, refreshIntervalMs) => { + const cpuAfter = await getCpuTime(cdp); + const { frames, updates, elapsedMs } = await page.evaluate(() => { + const perf = window.__perf; + perf.recording = false; + return { frames: perf.frames, updates: perf.updates, elapsedMs: performance.now() - perf.startedAt }; + }); + const seconds = elapsedMs / 1000; + return { + fps: frames.length / seconds, + frameP95: percentile(frames, 95), + jankPct: (frames.filter((f) => f > refreshIntervalMs * 1.5).length / Math.max(frames.length, 1)) * 100, + scriptMsPerSec: ((cpuAfter.script - cpuBefore.script) * 1000) / seconds, + taskMsPerSec: ((cpuAfter.task - cpuBefore.task) * 1000) / seconds, + domUpdatesPerSec: updates / seconds, + }; +}; + +const startRecording = (page) => + page.evaluate(() => Object.assign(window.__perf, { recording: true, frames: [], updates: 0, startedAt: performance.now() })); + +/** Moves the mouse across each fully visible chart in horizontal passes until `durationMs` elapses. */ +const sweepCharts = async (page, durationMs) => { + const boxes = ( + await page.locator(CHART_SELECTOR).evaluateAll((els) => els.map((el) => el.getBoundingClientRect().toJSON())) + ).filter((b) => b.width > 0 && b.top >= 0 && b.bottom <= VIEWPORT.height && b.right <= VIEWPORT.width); + if (!boxes.length) throw new Error('No fully visible charts found to hover.'); + + const end = Date.now() + durationMs; + const stepsPerPass = 40; + for (let pass = 0; Date.now() < end; pass++) { + const box = boxes[pass % boxes.length]; + const y = box.top + box.height * (0.3 + 0.2 * (pass % 3)); + for (let step = 0; step <= stepsPerPass && Date.now() < end; step++) { + await page.mouse.move(box.left + box.width * (0.1 + (0.8 * step) / stepsPerPass), y); + await page.waitForTimeout(MOUSE_STEP_MS); + } + } + await page.mouse.move(2, 2); +}; + +/** One fresh page load; returns stats for each requested scenario. */ +const runOnce = async (browser, config) => { + const context = await browser.newContext({ viewport: VIEWPORT }); + const page = await context.newPage(); + const measureDrawIn = config.scenarios.includes('drawIn'); + await page.addInitScript(instrumentPage); + const cdp = await context.newCDPSession(page); + await cdp.send('Performance.enable'); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: config.cpu }); + + // Storybook's HMR socket never goes network-idle, so wait for a rendered chart instead + const url = `${config.url}/iframe.html?id=${config.story}&viewMode=story&args=${encodeURIComponent(config.args)}`; + await page.goto(url, { waitUntil: 'load', timeout: 120_000 }); + await page.locator(CHART_SELECTOR).first().waitFor({ timeout: 60_000 }); + + // Draw-in starts on mount, so record immediately; assume 60Hz for its jank threshold + const results = {}; + if (measureDrawIn) { + const cpuBefore = await getCpuTime(cdp); + await startRecording(page); + await page.waitForTimeout(DRAW_IN_WINDOW_MS); + results.drawIn = await finishRecording(page, cdp, cpuBefore, 1000 / 60); + } + await page.waitForTimeout(2000); + const refreshIntervalMs = await page.evaluate( + () => new Promise((resolve) => { + const deltas = []; + let last = performance.now(); + const onFrame = (now) => { + deltas.push(now - last); + last = now; + if (deltas.length < 30) requestAnimationFrame(onFrame); + else resolve(deltas.sort((a, b) => a - b)[15]); + }; + requestAnimationFrame(onFrame); + }) + ); + + for (const scenario of config.scenarios.filter((s) => s !== 'drawIn')) { + const cpuBefore = await getCpuTime(cdp); + await startRecording(page); + if (scenario === 'hover') await sweepCharts(page, config.duration); + else await page.waitForTimeout(config.duration); + results[scenario] = await finishRecording(page, cdp, cpuBefore, refreshIntervalMs); + await page.waitForTimeout(1000); + } + + await context.close(); + return results; +}; + +const fmt = (value, digits = 1) => (Number.isFinite(value) ? value.toFixed(digits) : '-'); + +const medianOfRuns = (runs, scenario) => + Object.fromEntries(Object.keys(runs[0][scenario]).map((key) => [key, median(runs.map((r) => r[scenario][key]))])); + +const printTables = (results) => { + const scenarios = SCENARIOS.filter((s) => results.some((r) => r.runs[0][s])); + for (const scenario of scenarios) { + console.log(`\n${scenario} (median of runs)`); + console.table( + results + .filter((r) => r.runs[0][scenario]) + .map(({ label, runs }) => { + const stats = medianOfRuns(runs, scenario); + return { + label, + fps: fmt(stats.fps), + 'frame p95 ms': fmt(stats.frameP95), + 'jank %': fmt(stats.jankPct), + 'script ms/s': fmt(stats.scriptMsPerSec, 0), + 'task ms/s': fmt(stats.taskMsPerSec, 0), + 'DOM updates/s': fmt(stats.domUpdatesPerSec, 0), + }; + }) + ); + } + console.log('script/task ms/s = main-thread time per second (1000 = saturated). jank = frames > 1.5x refresh.'); +}; + +const compare = (files) => { + if (files.length < 2) throw new Error('--compare needs at least two result files.'); + const results = files.map((file) => JSON.parse(readFileSync(file, 'utf8'))); + const settings = new Set(results.map(({ config: c }) => `${c.story}|${c.args}|${c.cpu}|${c.duration}`)); + if (settings.size > 1) console.warn('Warning: results were recorded with different story/args/cpu/duration.'); + printTables(results); +}; + +const measure = async (config) => { + console.log(`[${config.label}] ${config.story}`); + console.log(`args: ${config.args || '-'} | ${config.scenarios.join(', ')} | CPU ${config.cpu}x | ${config.runs} run(s)`); + + const browser = await chromium.launch({ headless: !config.headed, channel: 'chromium' }); + const runs = []; + try { + for (let run = 1; run <= config.runs; run++) { + process.stdout.write(`run ${run}/${config.runs}... `); + runs.push(await runOnce(browser, config)); + console.log('done'); + } + } finally { + await browser.close(); + } + + printTables([{ label: config.label, runs }]); + mkdirSync(dirname(config.out), { recursive: true }); + writeFileSync(config.out, JSON.stringify({ label: config.label, config, runs }, null, 2)); + console.log(`\nResults written to ${config.out}`); +}; + +const main = async () => { + const config = getConfig(); + if (config.list) { + for (const [name, preset] of Object.entries(PRESETS)) console.log(`${name}: ${preset.scenarios.join(', ')} — ${preset.story}`); + } else if (config.compare) compare(config.files); + else await measure(config); +}; + +main().catch((error) => { + console.error(error.message ?? error); + process.exit(1); +}); From a8dfdb96dcc3509ec15dfa7516b5124f5fa8e82a Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:16:25 -0600 Subject: [PATCH 2/7] chore: drop benchmark note from changeset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/s2-animation-ticker.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/s2-animation-ticker.md b/.changeset/s2-animation-ticker.md index 7ce643c1e9..886dc434ae 100644 --- a/.changeset/s2-animation-ticker.md +++ b/.changeset/s2-animation-ticker.md @@ -9,4 +9,3 @@ S2 animations run on a shared on-demand ticker instead of each chart's always-on - 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. -- Add `yarn perf:animation` to benchmark draw-in, hover and idle cost in Storybook. From b87ff3d17ceb3794480cf7137baa03bd7294c34c Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:15:54 -0600 Subject: [PATCH 3/7] docs(s2): explain animation ticker design decisions in comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/animation/animationTicker.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts index f48bc44747..fff3dbef4e 100644 --- a/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts +++ b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts @@ -24,19 +24,25 @@ import { interface TickerEntry { view: View; container: Element; + /** Has animation work; cleared only after a tick reports `animationActive` false */ awake: boolean; + /** On screen per the IntersectionObserver; off-screen charts skip ticks */ visible: boolean; + /** A `runAsync` is in flight; prevents overlapping runs on the same view */ running: boolean; onActiveChange: (name: string, active: boolean) => void; } +// Map insertion order is the tick order; ticked entries are re-inserted at the back (round-robin) const entries = new Map(); const entriesByContainer = new Map(); let frameHandle: number | undefined; +// true while a frame's async runs are pending; the next frame is requested after they finish, so frames never overlap let frameRunning = false; let lastFrameTime = -Infinity; let observer: IntersectionObserver | undefined; +// setTimeout fallback for environments without rAF (SSR, some test runners) const requestFrame = (callback: (time: number) => void): number => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(callback) @@ -61,6 +67,7 @@ const scheduleFrame = (): void => { }; const readActive = (view: View): boolean => { + // a finalized view or a spec without the signal throws; treat both as inactive try { return Boolean(view.signal(ANIMATION_ACTIVE)); } catch { @@ -68,6 +75,7 @@ const readActive = (view: View): boolean => { } }; +// monotonic clock for measuring the frame budget; wall-clock jumps would break it const clock = (): number => (typeof performance === 'undefined' ? Date.now() : performance.now()); const tick = async (entry: TickerEntry, now: number): Promise => { @@ -76,18 +84,22 @@ const tick = async (entry: TickerEntry, now: number): Promise => { try { await view.signal(ANIMATION_TIMER, now).runAsync(); } catch { + // drop a failed or finalized view so it can't stall the loop for other charts entry.running = false; detach(entry); return; } entry.running = false; + // the view was detached or re-attached while the run was pending if (entries.get(view) !== entry) return; // only sleep once the spec reports every animation (including its final grace tick) has settled if (!readActive(view)) entry.awake = false; }; const runFrame = async (now: number, frameStart: number): Promise => { + // snapshot, because the loop reorders entries const due = [...entries.values()].filter((entry) => entry.awake && entry.visible && !entry.running); + // sequential so the budget can be measured; the check is after the tick, so at least one chart always ticks for (const entry of due) { await tick(entry, now); // ticked charts move to the back so charts skipped by the budget go first next frame @@ -112,6 +124,7 @@ function frame(time: number): void { }); return; } + // too soon for ANIMATION_MIN_FRAME_INTERVAL; skip this frame but keep the loop alive scheduleFrame(); } @@ -120,6 +133,7 @@ const wake = (entry: TickerEntry): void => { scheduleFrame(); }; +// one shared observer for every chart; lazily created and disconnected when the last chart detaches const getObserver = (): IntersectionObserver | undefined => { if (observer || typeof IntersectionObserver === 'undefined') return observer; observer = new IntersectionObserver((observed) => { @@ -142,6 +156,7 @@ function detach(entry: TickerEntry): void { observer?.disconnect(); observer = undefined; } + // don't wake for a frame that has nothing left to tick if (frameHandle !== undefined && !hasPendingWork()) { cancelFrame(frameHandle); frameHandle = undefined; @@ -162,6 +177,7 @@ export const isAnimatedSpec = (spec: Spec): boolean => */ export const removeAnimationTimerEvents = (spec: Spec): void => { const timer = spec.signals?.find((signal) => signal.name === ANIMATION_TIMER); + // removed from the spec rather than blocked with config.events.timer, which logs a warning per chart if (timer && 'on' in timer) delete timer.on; }; @@ -172,6 +188,7 @@ export const removeAnimationTimerEvents = (spec: Spec): void => { * @returns function that detaches the view from the ticker */ export const attachAnimationTicker = (view: View, container: Element): (() => void) => { + // re-attaching the same view replaces its entry const existing = entries.get(view); if (existing) detach(existing); @@ -179,8 +196,10 @@ export const attachAnimationTicker = (view: View, container: Element): (() => vo view, container, awake: false, + // assume visible until the observer's first callback so the first frames aren't skipped visible: true, running: false, + // only wakes; sleeping waits for a tick to see animationActive false so the final grace tick still runs onActiveChange: (_name, active) => { if (active) wake(entry); }, @@ -192,10 +211,12 @@ export const attachAnimationTicker = (view: View, container: Element): (() => vo return () => {}; } entries.set(view, entry); + // a re-embed into the same container replaces the old view, which may not have been detached yet const previous = entriesByContainer.get(container); if (previous) detach(previous); entriesByContainer.set(container, entry); getObserver()?.observe(container); + // the listener only fires on changes, so start ticking now if the spec begins active (e.g. draw-in) if (readActive(view)) wake(entry); return () => detach(entry); From 8bbf3018f227f18284347ecbb9ada548b23c81f0 Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:03:03 -0600 Subject: [PATCH 4/7] refactor(s2): drop test-only frame clock reset from ticker; clarify budget clock comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/animation/animationTicker.test.ts | 19 +++++++++++-------- .../src/animation/animationTicker.ts | 5 ++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts b/packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts index 1943822eb8..dd5dfc259a 100644 --- a/packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts +++ b/packages/react-spectrum-charts-s2/src/animation/animationTicker.test.ts @@ -13,7 +13,8 @@ import { Spec, View } from 'vega'; import { ANIMATION_ACTIVE, ANIMATION_FRAME_BUDGET_MS, ANIMATION_TIMER } from '@spectrum-charts/constants'; -import { attachAnimationTicker, isAnimatedSpec, removeAnimationTimerEvents } from './animationTicker'; +// loaded fresh per test so module-level frame state (e.g. lastFrameTime) doesn't leak between fake-timer clocks +let ticker: typeof import('./animationTicker'); type Listener = (name: string, value: boolean) => void; @@ -69,13 +70,15 @@ const flushFrames = async (frames: number) => { describe('animationTicker', () => { let detachers: (() => void)[] = []; const attach = (view: View, container: Element = document.createElement('div')) => { - const detach = attachAnimationTicker(view, container); + const detach = ticker.attachAnimationTicker(view, container); detachers.push(detach); return detach; }; - beforeEach(() => { + beforeEach(async () => { jest.useFakeTimers(); + jest.resetModules(); + ticker = await import('./animationTicker'); detachers = []; }); @@ -87,9 +90,9 @@ describe('animationTicker', () => { describe('isAnimatedSpec()', () => { test('detects the animation timer signal', () => { - expect(isAnimatedSpec({ signals: [{ name: ANIMATION_TIMER, value: 0 }] } as Spec)).toBe(true); - expect(isAnimatedSpec({ signals: [{ name: 'other', value: 0 }] } as Spec)).toBe(false); - expect(isAnimatedSpec({} as Spec)).toBe(false); + expect(ticker.isAnimatedSpec({ signals: [{ name: ANIMATION_TIMER, value: 0 }] } as Spec)).toBe(true); + expect(ticker.isAnimatedSpec({ signals: [{ name: 'other', value: 0 }] } as Spec)).toBe(false); + expect(ticker.isAnimatedSpec({} as Spec)).toBe(false); }); }); @@ -101,7 +104,7 @@ describe('animationTicker', () => { { name: 'other', value: 0, on: [{ events: 'click', update: '1' }] }, ], } as Spec; - removeAnimationTimerEvents(spec); + ticker.removeAnimationTimerEvents(spec); expect(spec.signals).toStrictEqual([ { name: ANIMATION_TIMER, value: 0 }, { name: 'other', value: 0, on: [{ events: 'click', update: '1' }] }, @@ -110,7 +113,7 @@ describe('animationTicker', () => { test('is a no-op for specs without the animation timer', () => { const spec = {} as Spec; - removeAnimationTimerEvents(spec); + ticker.removeAnimationTimerEvents(spec); expect(spec).toStrictEqual({}); }); }); diff --git a/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts index fff3dbef4e..af9c014568 100644 --- a/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts +++ b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts @@ -75,7 +75,7 @@ const readActive = (view: View): boolean => { } }; -// monotonic clock for measuring the frame budget; wall-clock jumps would break it +// precise elapsed time within a frame for the budget; the spec's animation timer uses Date.now() separately const clock = (): number => (typeof performance === 'undefined' ? Date.now() : performance.now()); const tick = async (entry: TickerEntry, now: number): Promise => { @@ -113,8 +113,7 @@ const runFrame = async (now: number, frameStart: number): Promise => { function frame(time: number): void { frameHandle = undefined; - // time < lastFrameTime means the frame clock was reset (e.g. a new document timeline) - if (time < lastFrameTime || time - lastFrameTime >= ANIMATION_MIN_FRAME_INTERVAL) { + if (time - lastFrameTime >= ANIMATION_MIN_FRAME_INTERVAL) { lastFrameTime = time; frameRunning = true; // wall-clock time to match the spec's now()-based animation timestamps From 0f5bf3d00cd86448d0c4add03afab4511b7bfdd0 Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:04:19 -0600 Subject: [PATCH 5/7] docs(s2): explain ticker entries, ticks, frame budget and clocks in comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/VegaChart.tsx | 1 + .../src/animation/animationTicker.ts | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.tsx index 15072665bc..9f5d4ab7b8 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.tsx @@ -146,6 +146,7 @@ export const VegaChart: FC = ({ // 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 }) => { diff --git a/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts index af9c014568..a321fff71c 100644 --- a/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts +++ b/packages/react-spectrum-charts-s2/src/animation/animationTicker.ts @@ -23,6 +23,7 @@ import { interface TickerEntry { view: View; + /** Wrapper element passed to embed (not the svg/canvas), so visibility works for either renderer */ container: Element; /** Has animation work; cleared only after a tick reports `animationActive` false */ awake: boolean; @@ -33,8 +34,9 @@ interface TickerEntry { onActiveChange: (name: string, active: boolean) => void; } -// Map insertion order is the tick order; ticked entries are re-inserted at the back (round-robin) +// Every chart attached to the ticker. Map insertion order is the tick order; ticked entries are re-inserted at the back (round-robin) const entries = new Map(); +// IntersectionObserver reports elements, so visibility updates look entries up by container const entriesByContainer = new Map(); let frameHandle: number | undefined; // true while a frame's async runs are pending; the next frame is requested after they finish, so frames never overlap @@ -78,6 +80,11 @@ const readActive = (view: View): boolean => { // precise elapsed time within a frame for the budget; the spec's animation timer uses Date.now() separately const clock = (): number => (typeof performance === 'undefined' ? Date.now() : performance.now()); +/** + * One animation update for one chart: sets the timer signal, runs the view, and sleeps it once nothing is animating. + * @param entry - the chart to tick + * @param now - wall-clock time for the timer signal + */ const tick = async (entry: TickerEntry, now: number): Promise => { const { view } = entry; entry.running = true; @@ -92,10 +99,16 @@ const tick = async (entry: TickerEntry, now: number): Promise => { entry.running = false; // the view was detached or re-attached while the run was pending if (entries.get(view) !== entry) return; - // only sleep once the spec reports every animation (including its final grace tick) has settled + // sleep only after the spec settles, including hover's grace tick: one extra tick that lands it exactly on its resting value if (!readActive(view)) entry.awake = false; }; +/** + * Ticks due charts until the frame budget is spent, leaving the rest of the frame for paint and input. + * Skipped charts catch up next frame without slowing down, since animation progress is wall-clock. + * @param now - wall-clock time for every tick in this frame + * @param frameStart - clock() at frame start, for the budget + */ const runFrame = async (now: number, frameStart: number): Promise => { // snapshot, because the loop reorders entries const due = [...entries.values()].filter((entry) => entry.awake && entry.visible && !entry.running); @@ -116,7 +129,7 @@ function frame(time: number): void { if (time - lastFrameTime >= ANIMATION_MIN_FRAME_INTERVAL) { lastFrameTime = time; frameRunning = true; - // wall-clock time to match the spec's now()-based animation timestamps + // Date.now(), not the rAF time: the spec compares the timer with its own now() values (e.g. hover's lastChange) runFrame(Date.now(), clock()).finally(() => { frameRunning = false; scheduleFrame(); From aafebee12c71038d0435a736ed8637cc1a604582 Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:47:26 -0600 Subject: [PATCH 6/7] fix(s2): discard views whose embed resolves after unmount Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/VegaChart.test.tsx | 16 ++++++++++++++++ .../react-spectrum-charts-s2/src/VegaChart.tsx | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index 909f104970..ae1366043c 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -176,6 +176,22 @@ describe('VegaChart init render cycle', () => { expect(mockDetachAnimationTicker).toHaveBeenCalledTimes(1); }); + test('discards a view whose embed resolves after unmount', async () => { + let resolveEmbed: (value: Awaited>) => void = () => {}; + mockEmbed.mockReturnValueOnce(new Promise((resolve) => (resolveEmbed = resolve))); + const view = createMockView(); + const { unmount } = render(); + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + + unmount(); + resolveEmbed({ view } as unknown as Awaited>); + 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(); diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.tsx index 9f5d4ab7b8..3a54842294 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.tsx @@ -124,6 +124,7 @@ export const VegaChart: FC = ({ }, [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); @@ -150,6 +151,11 @@ export const VegaChart: FC = ({ 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); @@ -162,6 +168,7 @@ export const VegaChart: FC = ({ }); } return () => { + cancelled = true; // destroy the chart on unmount detachAnimationTicker.current?.(); detachAnimationTicker.current = undefined; From 336cadca744570d7e66b565a1ae602a1e0287799 Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:21:16 -0600 Subject: [PATCH 7/7] refactor(s2): drive draw-in active state from drawInAnimT instead of a data source The data-backed condition only fed the timer filter, which the ticker strips, and benchmarked slightly slower. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/constants/constants.ts | 2 -- .../src/line/lineSpecBuilder.test.ts | 6 ---- .../src/line/lineSpecBuilder.ts | 3 +- .../src/marks/animationTimerUtils.test.ts | 15 ++++++++++ .../src/marks/animationTimerUtils.ts | 24 ++++++++++++---- .../src/marks/drawInAnimationUtils.test.ts | 28 ++----------------- .../src/marks/drawInAnimationUtils.ts | 20 ++----------- .../hover-animation-system.md | 6 ++-- 8 files changed, 43 insertions(+), 61 deletions(-) diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts index 794b870f97..3f5e8d1fa9 100644 --- a/packages/constants/constants.ts +++ b/packages/constants/constants.ts @@ -120,8 +120,6 @@ export const FILTERED_TABLE = 'filteredTable'; export const CONTROLLED_HIGHLIGHTED_TABLE = 'controlledHighlightedTable'; /** Single-row data source recording timestamp of the most recent hover target change */ export const HOVER_ANIM_LAST_CHANGE_DATA = 'hoverAnimLastChangeData'; -/** Single-row data source recording whether the draw-in animation has finished */ -export const DRAW_IN_CLOCK_DATA = 'drawInClockData'; export const HOVER_TARGET_DATA = 'hoverTargetData'; export const HOVER_ANIM_STATE_DATA = 'hoverAnimStateData'; export const HOVER_FRACTION_DATA = 'hoverFractionData'; diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts index 41e55be684..452be6b61d 100644 --- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts @@ -22,7 +22,6 @@ import { DEFAULT_STROKE_WIDTH_RULE, DEFAULT_TIME_DIMENSION, DEFAULT_TRANSFORMED_TIME_DIMENSION, - DRAW_IN_CLOCK_DATA, FILTERED_TABLE, GROUP_ID, HOVERED_ITEM, @@ -763,11 +762,6 @@ describe('lineSpecBuilder', () => { expect(resultData.find((d) => d.name === 'line0_drawInLerp')).toBeDefined(); }); - test('adds the draw-in clock data so the animation timer can stop when draw-in finishes', () => { - const resultData = addData(baseData, { ...defaultLineOptions, isDrawInAnimate: true, scaleType: 'time' }); - expect(resultData.find((d) => d.name === DRAW_IN_CLOCK_DATA)).toBeDefined(); - }); - test('for a linear scale, adds the lead transform on filteredTable without the ms-formula transform', () => { const resultData = addData(baseData, { ...defaultLineOptions, isDrawInAnimate: true, scaleType: 'linear' }); const tableData = resultData.find((d) => d.name === TABLE); diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts index 5fe550d2a6..4c542a9200 100644 --- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts @@ -90,7 +90,7 @@ import { import { getLinePointAnnotationMarks } from './linePointAnnotation'; import { getLineStaticPoint, getLineStaticPointBackground } from './linePointUtils'; import { getPopoverMarkName, isDualMetricAxis } from './lineUtils'; -import { addDrawInClockData, addLineDrawInAnimationSignals, addLineDrawInLeadTransform, addLineDrawInTimeMsTransform, getLineDrawInData, getLineDrawInPointIndexData } from '../marks/drawInAnimationUtils'; +import { addLineDrawInAnimationSignals, addLineDrawInLeadTransform, addLineDrawInTimeMsTransform, getLineDrawInData, getLineDrawInPointIndexData } from '../marks/drawInAnimationUtils'; export const addLine = produce< ScSpec, @@ -279,7 +279,6 @@ export const addData = produce((data, options) => { addLineHoverData(data, options); if (options.isDrawInAnimate) { - addDrawInClockData(data); if (scaleType === 'point') { const pointIndexData = getLineDrawInPointIndexData(options); addLineDrawInLeadTransform(pointIndexData, options); diff --git a/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts index 0ad688b65b..9665d3aebb 100644 --- a/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.test.ts @@ -52,6 +52,21 @@ describe('addAnimationTimerSignal()', () => { expect(getActiveUpdate(signals)).toEqual(`(a(${ANIMATION_TIMER}))`); }); + test('leaves the timer unfiltered when the condition reads signals', () => { + const signals: Signal[] = []; + addAnimationTimerSignal(signals, condition('a'), true); + expect(getTimerFilter(signals)).toBeUndefined(); + expect(getActiveUpdate(signals)).toEqual(`(a(${ANIMATION_TIMER}))`); + }); + + test('removes an existing timer filter when a signal-reading condition is added', () => { + const signals: Signal[] = []; + addAnimationTimerSignal(signals, condition('a')); + addAnimationTimerSignal(signals, condition('b'), true); + expect(getTimerFilter(signals)).toBeUndefined(); + expect(getActiveUpdate(signals)).toEqual(`(a(${ANIMATION_TIMER})) || (b(${ANIMATION_TIMER}))`); + }); + test('leaves an existing timer without a string filter untouched', () => { const signals: Signal[] = [ { name: ANIMATION_TIMER, value: 0, on: [{ events: { type: 'timer' }, update: 'now()' }] }, diff --git a/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts index 3a6184cf90..5a967168a8 100644 --- a/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts +++ b/packages/vega-spec-builder-s2/src/marks/animationTimerUtils.ts @@ -13,7 +13,7 @@ import { EventStream, Signal } from 'vega'; import { ANIMATION_ACTIVE, ANIMATION_THROTTLE, ANIMATION_TIMER } from '@spectrum-charts/constants'; -/** Builds a data-only vega expression that is true while an animation still needs ticks, given a clock expression. */ +/** Builds a vega expression that is true while an animation still needs ticks, given a clock expression. */ export type AnimationActiveCondition = (clock: string) => string; /** @@ -28,10 +28,15 @@ const orCondition = (expr: string, condition: string): string => /** * Adds the shared animation timer and `animationActive` signals (if missing) and ORs `getCondition` into both. * @param signals - the signals array to add the animation timer to - * @param getCondition - builds the active condition; event filters cannot read signals, so it may only reference data + * @param getCondition - builds the active condition + * @param readsSignals - true if the condition reads signals; event filters can't, so the timer is left unfiltered */ -export const addAnimationTimerSignal = (signals: Signal[], getCondition: AnimationActiveCondition): void => { - const filterCondition = `(${getCondition('now()')})`; +export const addAnimationTimerSignal = ( + signals: Signal[], + getCondition: AnimationActiveCondition, + readsSignals = false +): void => { + const filterCondition = readsSignals ? undefined : `(${getCondition('now()')})`; const activeCondition = `(${getCondition(ANIMATION_TIMER)})`; const timer = signals.find((signal) => signal.name === ANIMATION_TIMER); @@ -39,12 +44,19 @@ export const addAnimationTimerSignal = (signals: Signal[], getCondition: Animati signals.push({ name: ANIMATION_TIMER, value: 0, - on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE, filter: filterCondition }, update: 'now()' }], + on: [ + { + events: { type: 'timer', throttle: ANIMATION_THROTTLE, ...(filterCondition && { filter: filterCondition }) }, + update: 'now()', + }, + ], }); } else { const events = timer.on?.[0]?.events as EventStream | undefined; if (events && typeof events.filter === 'string') { - events.filter = orCondition(events.filter, filterCondition); + // an unfilterable condition means any tick may be needed, so the timer can't filter at all + if (filterCondition) events.filter = orCondition(events.filter, filterCondition); + else delete events.filter; } } diff --git a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts index 705b4d01db..36cf5803ae 100644 --- a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.test.ts @@ -17,7 +17,6 @@ import { ANIMATION_TIMER, DEFAULT_TRANSFORMED_TIME_DIMENSION, DRAW_IN_ANIMATION_DURATION_MS, - DRAW_IN_CLOCK_DATA, FILTERED_TABLE, LAST_RSC_SERIES_ID, SERIES_ID, @@ -26,7 +25,6 @@ import { import { defaultLineMarkOptions, defaultLineOptions } from '../line/lineTestUtils'; import { LineSpecOptions } from '../types'; import { - addDrawInClockData, addDrawInClockSignals, addLineDrawInAnimationSignals, addLineDrawInLeadTransform, @@ -215,23 +213,6 @@ describe('getLineDrawInData()', () => { }); }); -describe('addDrawInClockData()', () => { - test('adds a single done-flag data source triggered by drawInAnimT', () => { - const data: Data[] = []; - addDrawInClockData(data); - addDrawInClockData(data); - expect(data).toStrictEqual([ - { - name: DRAW_IN_CLOCK_DATA, - values: [{ done: false }], - on: [ - { trigger: 'drawInAnimT', modify: `data('${DRAW_IN_CLOCK_DATA}')[0]`, values: '{done: drawInAnimT >= 1}' }, - ], - }, - ]); - }); -}); - describe('addDrawInClockSignals()', () => { test('adds the shared mount-timer chain', () => { const signals: Signal[] = []; @@ -240,14 +221,9 @@ describe('addDrawInClockSignals()', () => { { name: ANIMATION_TIMER, value: 0, - on: [ - { - events: { type: 'timer', throttle: ANIMATION_THROTTLE, filter: `(!data('${DRAW_IN_CLOCK_DATA}')[0].done)` }, - update: 'now()', - }, - ], + on: [{ events: { type: 'timer', throttle: ANIMATION_THROTTLE }, update: 'now()' }], }, - { name: ANIMATION_ACTIVE, value: true, update: `(!data('${DRAW_IN_CLOCK_DATA}')[0].done)` }, + { name: ANIMATION_ACTIVE, value: true, update: '(drawInAnimT < 1)' }, { name: 'drawInStart', value: 0, diff --git a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts index 9650c365e1..1c0a179039 100644 --- a/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts +++ b/packages/vega-spec-builder-s2/src/marks/drawInAnimationUtils.ts @@ -19,7 +19,6 @@ import { DRAW_IN_ANIM_T, DRAW_IN_ANIM_T_EASED, DRAW_IN_ANIMATION_DURATION_MS, - DRAW_IN_CLOCK_DATA, DRAW_IN_DOMAIN_MAX, DRAW_IN_DOMAIN_MIN, DRAW_IN_LERP_DATA, @@ -179,23 +178,10 @@ export const getLineDrawInData = (options: LineSpecOptions): Data[] => { }; /** - * Adds the single-row data source that records when draw-in finishes so the animation timer can stop ticking. - * @param data - the data array to add the draw-in clock data to - */ -export const addDrawInClockData = (data: Data[]): void => { - if (data.some((d) => d.name === DRAW_IN_CLOCK_DATA)) return; - data.push({ - name: DRAW_IN_CLOCK_DATA, - values: [{ done: false }], - on: [{ trigger: DRAW_IN_ANIM_T, modify: `data('${DRAW_IN_CLOCK_DATA}')[0]`, values: `{done: ${DRAW_IN_ANIM_T} >= 1}` }], - }); -}; - -/** - * Gets the data-only condition that is true until the draw-in animation has finished. + * Gets the condition that is true until the draw-in animation has finished. * @returns string */ -export const getDrawInAnimationActiveCondition = (): string => `!data('${DRAW_IN_CLOCK_DATA}')[0].done`; +export const getDrawInAnimationActiveCondition = (): string => `${DRAW_IN_ANIM_T} < 1`; /** * Adds the shared mount-timer chain (`drawInStart` -> `drawInAnimT` -> `drawInAnimTEased`) every @@ -205,7 +191,7 @@ export const getDrawInAnimationActiveCondition = (): string => `!data('${DRAW_IN * `drawInAnimTEased` - the animation progress with easing applied. Current easing formula is in-out quadratic */ export const addDrawInClockSignals = (signals: Signal[]): void => { - addAnimationTimerSignal(signals, getDrawInAnimationActiveCondition); + addAnimationTimerSignal(signals, getDrawInAnimationActiveCondition, true); if (!hasSignalByName(signals, DRAW_IN_START)) { // starts on the first timer tick (the first painted frame) so a slow mount doesn't consume the animation signals.push({ diff --git a/planning/research/hover-animation-system/hover-animation-system.md b/planning/research/hover-animation-system/hover-animation-system.md index 4b5e7992f4..1e1a86f266 100644 --- a/planning/research/hover-animation-system/hover-animation-system.md +++ b/planning/research/hover-animation-system/hover-animation-system.md @@ -253,10 +253,12 @@ The spec still carries the timer (`animationTimer`, throttle 33), so a standalon The spec also emits an `animationActive` signal: the OR of each animation's "still moving" condition. - Hover: `!hoverAnimLastChangeData[0].settled || clock - lastChange < SPEED + THROTTLE`. `settled` is set by `hoverIdleTicks >= 2`. -- Draw-in: `!drawInClockData[0].done`. `done` is set by `drawInAnimT >= 1`. +- Draw-in: `drawInAnimT < 1`. Both are built by `addAnimationTimerSignal` (`vega-spec-builder-s2/src/marks/animationTimerUtils.ts`). It -also ORs the same conditions into the timer event's `filter`. The filter can only use `data()`, not signals. +also ORs the same conditions into the timer event's `filter`. The filter can only use `data()`, not signals, +so draw-in leaves the timer unfiltered. A data-backed draw-in condition was measured and dropped: it was +slightly slower and only helps standalone Vega consumers, because the ticker strips the filter. At embed time (`react-spectrum-charts-s2/src/animation/animationTicker.ts`, framework-agnostic): 1. `removeAnimationTimerEvents(spec)` strips the timer's `on` handler. It is not blocked through