From 107bd207dad7737ef6da188e8cff39253a9ddf07 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 16:23:32 -0600 Subject: [PATCH] Migrate catalog examples to React --- API-FRICTION.md | 48 +- .../112-motion-entrance/tanstack.test.ts | 6 +- .../cases/112-motion-entrance/tanstack.ts | 304 +---- .../cases/112-motion-entrance/view.tsx | 237 ++++ .../cases/113-motion-updates/tanstack.test.ts | 21 +- .../cases/113-motion-updates/tanstack.ts | 328 +---- .../cases/113-motion-updates/view.tsx | 253 ++++ .../114-spring-line-motion/tanstack.test.ts | 23 +- .../cases/114-spring-line-motion/tanstack.ts | 196 +-- .../cases/114-spring-line-motion/view.tsx | 186 +++ .../115-definition-motion/tanstack.test.ts | 25 +- .../cases/115-definition-motion/tanstack.ts | 171 +-- .../cases/115-definition-motion/view.tsx | 162 +++ .../cases/116-geometry-morph/tanstack.test.ts | 18 +- .../cases/116-geometry-morph/tanstack.ts | 188 +-- .../cases/116-geometry-morph/view.tsx | 185 +++ .../117-focus-cursor-motion/tanstack.test.ts | 33 +- .../cases/117-focus-cursor-motion/tanstack.ts | 166 +-- .../cases/117-focus-cursor-motion/view.tsx | 145 +++ .../118-token-usage-calendar/shell.test.ts | 34 +- .../cases/118-token-usage-calendar/shell.ts | 172 --- .../cases/118-token-usage-calendar/shell.tsx | 189 +++ .../118-token-usage-calendar/tanstack.test.ts | 2 +- .../85-scrollable-resource-lanes/layout.ts | 24 + .../85-scrollable-resource-lanes/shell.ts | 36 +- .../tanstack.test.ts | 4 +- .../85-scrollable-resource-lanes/tanstack.ts | 273 +--- .../85-scrollable-resource-lanes/view.tsx | 466 +++++++ .../tanstack.test.ts | 64 +- .../tanstack.ts | 86 +- .../87-echarts-synchronized-cursors/view.tsx | 254 ++++ .../cases/88-echarts-free-cursor/controls.ts | 7 +- .../cases/88-echarts-free-cursor/format.ts | 5 + .../88-echarts-free-cursor/tanstack.test.ts | 61 +- .../cases/88-echarts-free-cursor/tanstack.ts | 147 +-- .../cases/88-echarts-free-cursor/view.tsx | 277 ++++ .../89-brush-range-selection/tanstack.test.ts | 38 +- .../89-brush-range-selection/tanstack.ts | 123 +- .../cases/89-brush-range-selection/view.tsx | 132 ++ .../90-zoomable-time-window/tanstack.test.ts | 34 +- .../cases/90-zoomable-time-window/tanstack.ts | 174 +-- .../cases/90-zoomable-time-window/view.tsx | 175 +++ .../tanstack.test.ts | 46 +- .../91-timeline-playback-scrubber/tanstack.ts | 247 +--- .../91-timeline-playback-scrubber/view.tsx | 263 ++++ .../cases/92-editable-event-range/controls.ts | 190 --- .../92-editable-event-range/tanstack.test.ts | 36 +- .../cases/92-editable-event-range/tanstack.ts | 159 +-- .../cases/92-editable-event-range/view.tsx | 348 +++++ .../conformance/shared/react-controls.tsx | 96 ++ examples/conformance/index.html | 2 +- examples/conformance/src/main.ts | 952 -------------- examples/conformance/src/main.tsx | 1123 +++++++++++++++++ examples/conformance/src/source-view.test.ts | 4 +- examples/conformance/src/source-view.ts | 110 -- examples/conformance/src/source-view.tsx | 134 ++ 56 files changed, 5081 insertions(+), 4101 deletions(-) create mode 100644 benchmarks/conformance/cases/112-motion-entrance/view.tsx create mode 100644 benchmarks/conformance/cases/113-motion-updates/view.tsx create mode 100644 benchmarks/conformance/cases/114-spring-line-motion/view.tsx create mode 100644 benchmarks/conformance/cases/115-definition-motion/view.tsx create mode 100644 benchmarks/conformance/cases/116-geometry-morph/view.tsx create mode 100644 benchmarks/conformance/cases/117-focus-cursor-motion/view.tsx delete mode 100644 benchmarks/conformance/cases/118-token-usage-calendar/shell.ts create mode 100644 benchmarks/conformance/cases/118-token-usage-calendar/shell.tsx create mode 100644 benchmarks/conformance/cases/85-scrollable-resource-lanes/layout.ts create mode 100644 benchmarks/conformance/cases/85-scrollable-resource-lanes/view.tsx create mode 100644 benchmarks/conformance/cases/87-echarts-synchronized-cursors/view.tsx create mode 100644 benchmarks/conformance/cases/88-echarts-free-cursor/format.ts create mode 100644 benchmarks/conformance/cases/88-echarts-free-cursor/view.tsx create mode 100644 benchmarks/conformance/cases/89-brush-range-selection/view.tsx create mode 100644 benchmarks/conformance/cases/90-zoomable-time-window/view.tsx create mode 100644 benchmarks/conformance/cases/91-timeline-playback-scrubber/view.tsx delete mode 100644 benchmarks/conformance/cases/92-editable-event-range/controls.ts create mode 100644 benchmarks/conformance/cases/92-editable-event-range/view.tsx create mode 100644 benchmarks/conformance/shared/react-controls.tsx delete mode 100644 examples/conformance/src/main.ts create mode 100644 examples/conformance/src/main.tsx delete mode 100644 examples/conformance/src/source-view.ts create mode 100644 examples/conformance/src/source-view.tsx diff --git a/API-FRICTION.md b/API-FRICTION.md index 3355ea55..ca926dba 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5237,32 +5237,38 @@ Each entry records: - Status: resolved - Severity: high - Owner: Documentation/Tooling -- Observed in: catalog review of the axis-pointer tooltip, interactive legend, - linked data table, focus/context window, pinned nested-chart tooltip, - streaming controls, synchronized and free cursors, range brush, and time zoom +- Observed in: catalog review of the catalog application, axis-pointer + tooltip, interactive legend, linked data table, focus/context window, pinned + nested-chart tooltip, resource timeline, streaming controls, synchronized and + free cursors, range brush, time zoom, playback, editable range, motion, and + calendar examples - Friction: the public TanStack examples assembled application-owned legends, tables, tooltip rows, buttons, and nested chart containers with long `createElement`, mutation, and listener blocks. The chart grammar remained visible, but the surrounding composition was unfamiliar copy-paste material for the primary React audience and obscured the adapter's intended ownership - boundary. -- Decision: render application-owned composition with React and - `@tanstack/react-charts`, while keeping chart-only examples and the direct - `mountChart` lifecycle framework-neutral. A small conformance-only React - mount adapter translates the benchmark's mount, update, driver, and destroy - contract without entering authored-source totals. Catalog source discovery, - artifact validation, and raw-source publication now follow `.tsx` support - modules and classify the React bridge as harness code. -- Verification: the focused TypeScript build reports zero diagnostics; source - loader, artifact, source-file, and source-view suites pass; and the - schema-v4 catalog build publishes all 102 cases with valid recursive source - closures. The definition-shape check follows migrated `view.tsx` modules, and - the loading graph accepts their lazy `.tsx?raw` source entries. Chromium - quick-profile checks pass visual and semantic interaction scenarios for all - ten migrated cases at 320px and 640px across both data revisions. Their - authored-source ratios remain 0.80–1.17× of the selected references, and the - measured isolated bundles include React rather than silently treating it as - benchmark-external infrastructure. + boundary. The catalog application itself still used `innerHTML`, string + templates, selector rebinding, and manual listener cleanup, and later + interaction and motion examples repeated the same application-shell pattern. +- Decision: assume React for the catalog application and all application-owned + example composition, using `@tanstack/charts/react`. Keep direct DOM access + only at actual browser integration boundaries such as metadata, measurement, + renderer mounting, and conformance inspection. Chart-only lifecycle fixtures + and third-party reference adapters may stay imperative when they do not + author application UI. A small conformance-only React mount adapter translates + the benchmark's mount, update, driver, and destroy contract without entering + authored-source totals. Catalog source discovery, artifact validation, and + raw-source publication follow `.tsx` support modules and classify the React + bridge as harness code. +- Verification: root TypeScript reports zero diagnostics; the focused React + example, source-loader, source-file, source-view, and catalog-index suites + pass; and the production catalog build publishes all 111 cases with valid + recursive source closures. Chromium quick-profile checks pass visual and + semantic interaction scenarios for all 14 examples migrated in the follow-up + at 320px and 640px across both data revisions. The browser run also verifies + preserved horizontal scroll, semantic synchronized focus, free-cursor input + precision, brush and zoom controls, playback, editable dates, motion, and the + calendar shell. ### F-179 — Animation clocks drift at fixed frame indices diff --git a/benchmarks/conformance/cases/112-motion-entrance/tanstack.test.ts b/benchmarks/conformance/cases/112-motion-entrance/tanstack.test.ts index 14247b42..02390b06 100644 --- a/benchmarks/conformance/cases/112-motion-entrance/tanstack.test.ts +++ b/benchmarks/conformance/cases/112-motion-entrance/tanstack.test.ts @@ -43,7 +43,7 @@ describe('declarative entrance motion', () => { const source = readFileSync( resolve( process.cwd(), - 'benchmarks/conformance/cases/112-motion-entrance/tanstack.ts', + 'benchmarks/conformance/cases/112-motion-entrance/view.tsx', ), 'utf8', ) @@ -54,8 +54,8 @@ describe('declarative entrance motion', () => { const definitionSource = source.slice(start, end) expect(source).toContain("from '../../shared/motion'") - expect(source).toContain('controls.replay.addEventListener') - expect(source).toContain('settleChartMotion(chart') + expect(source).toContain('') + expect(source).toContain('settleChartMotion(viewRef.current') expect(definitionSource).toContain('defineChart({') expect(definitionSource).toContain('motion: {') expect(definitionSource).toContain('barY(rows, {') diff --git a/benchmarks/conformance/cases/112-motion-entrance/tanstack.ts b/benchmarks/conformance/cases/112-motion-entrance/tanstack.ts index 9d5c7113..8fa643ea 100644 --- a/benchmarks/conformance/cases/112-motion-entrance/tanstack.ts +++ b/benchmarks/conformance/cases/112-motion-entrance/tanstack.ts @@ -1,302 +1,2 @@ -import { barY, defineChart, lineY } from '@tanstack/charts' -import { motion } from '@tanstack/charts/motion' -import { mountChartRenderer } from '@tanstack/charts/renderer' -import { scaleBand, scaleLinear } from 'd3-scale' -import { readChartMotionState, settleChartMotion } from '../../shared/motion' -import { entranceRows as rows } from './model' -import { tanstackCase } from '../../shared/mount' -import type { - ChartMotionTweenTransition, - ChartRenderer, - ChartRendererHost, -} from '@tanstack/charts' -import type { MotionRow } from './model' -import type { - ConformanceInput, - ConformanceMount, - ConformanceTestDriver, -} from '../../types' - -export interface MotionSettings { - duration: number - staggerMs: number - easing: ChartMotionTweenTransition['easing'] - customTiming: boolean -} - -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let replayCount = 0 - let host: ChartRendererHost | undefined - let renderer: ChartRenderer | undefined - const settings: MotionSettings = { - duration: 1_100, - staggerMs: 55, - easing: undefined, - customTiming: true, - } - const view = container.ownerDocument.createElement('div') - const controls = createControls(container.ownerDocument, settings) - const chart = container.ownerDocument.createElement('div') - view.dataset.conformanceView = 'main' - Object.assign(view.style, { - display: 'grid', - gridTemplateRows: 'auto minmax(0, 1fr)', - height: `${input.height}px`, - color: 'CanvasText', - }) - chart.style.minHeight = '0' - view.append(controls.root, chart) - container.append(view) - const chartHeight = () => - Math.max( - 180, - currentInput.height - controls.root.getBoundingClientRect().height, - ) - - const replay = () => { - host?.destroy() - replayCount += 1 - renderer = motion() - host = mountChartRenderer(chart, { - definition: motionEntranceDefinition(settings), - renderer, - width: currentInput.width, - height: chartHeight(), - ariaLabel: 'Staggered monthly actuals and target', - }) - } - - controls.replay.addEventListener('click', replay) - controls.duration.addEventListener('input', () => { - controls.durationValue.value = `${controls.duration.value} ms` - }) - controls.duration.addEventListener('change', () => { - settings.duration = Number(controls.duration.value) - replay() - }) - controls.stagger.addEventListener('input', () => { - controls.staggerValue.value = `${controls.stagger.value} ms` - }) - controls.stagger.addEventListener('change', () => { - settings.staggerMs = Number(controls.stagger.value) - replay() - }) - controls.easing.addEventListener('change', () => { - settings.easing = readEasing(controls.easing.value) - replay() - }) - controls.customTiming.addEventListener('change', () => { - settings.customTiming = controls.customTiming.checked - replay() - }) - replay() - - const driver: ConformanceTestDriver = { - resolveTarget(target) { - if (target.view && target.view !== 'main') return null - if (target.anchor !== 'control:replay') return null - const bounds = controls.replay.getBoundingClientRect() - return { - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - focusElement: controls.replay, - } - }, - readState() { - return { - duration: settings.duration, - staggerMs: settings.staggerMs, - customTiming: settings.customTiming, - replayCount, - motionState: readChartMotionState(chart), - } - }, - settle: () => settleChartMotion(chart, settings.duration * 1.6), - } - - return { - driver, - update(nextInput) { - currentInput = nextInput - view.style.height = `${nextInput.height}px` - if (!renderer) return - host?.update({ - definition: motionEntranceDefinition(settings), - renderer, - width: nextInput.width, - height: chartHeight(), - ariaLabel: 'Staggered monthly actuals and target', - }) - }, - destroy() { - host?.destroy() - view.remove() - }, - } -} - -export function motionEntranceDefinition(settings: MotionSettings) { - const { duration, easing, staggerMs, customTiming } = settings - return defineChart({ - motion: { - transition: { type: 'tween', duration, easing }, - }, - marks: [ - barY(rows, { - x: 'period', - y: 'actual', - key: 'id', - fill: '#7c3aed', - radius: 7, - inset: 4, - motion(context) { - if (customTiming && context.datum?.featured) { - return { - delay: duration * 0.19, - transition: { type: 'tween', duration: duration * 0.64 }, - } - } - return { delay: context.datumIndex * staggerMs } - }, - }), - lineY(rows, { - x: 'period', - y: 'target', - key: 'id', - stroke: '#f97316', - strokeWidth: 3, - motion: customTiming - ? { - delay: duration * 0.1, - transition: { type: 'tween', duration: duration * 0.78 }, - } - : undefined, - }), - ], - x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, - y: { scale: scaleLinear().domain([0, 100]) }, - guides: false, - margin: { top: 20, right: 20, bottom: 20, left: 20 }, - }) -} - -export const catalogCase = tanstackCase( - () => - motionEntranceDefinition({ - duration: 1_100, - staggerMs: 55, - easing: undefined, - customTiming: true, - }), - 'Staggered monthly actuals and target', -) - -function readEasing(value: string): MotionSettings['easing'] { - return value === 'linear' || - value === 'ease' || - value === 'ease-in' || - value === 'ease-out' || - value === 'ease-in-out' - ? value - : undefined -} - -function createControls(document: Document, settings: MotionSettings) { - const root = document.createElement('div') - root.setAttribute('role', 'group') - root.setAttribute('aria-label', 'Entrance motion controls') - Object.assign(root.style, { - display: 'flex', - alignItems: 'center', - alignContent: 'center', - flexWrap: 'wrap', - gap: '8px 12px', - padding: '8px 10px', - font: '500 12px/1.2 system-ui, sans-serif', - }) - - const duration = range(document, 300, 1_800, 100, settings.duration) - const durationValue = document.createElement('output') - durationValue.value = `${settings.duration} ms` - const stagger = range(document, 0, 120, 5, settings.staggerMs) - const staggerValue = document.createElement('output') - staggerValue.value = `${settings.staggerMs} ms` - const easing = document.createElement('select') - for (const [value, label] of [ - ['polished', 'Polished'], - ['ease', 'Ease'], - ['ease-out', 'Ease out'], - ['ease-in-out', 'Ease in/out'], - ['linear', 'Linear'], - ]) { - const option = document.createElement('option') - option.value = value - option.textContent = label - easing.append(option) - } - const customTiming = document.createElement('input') - customTiming.type = 'checkbox' - customTiming.checked = settings.customTiming - const replay = button(document, 'Replay') - root.append( - field(document, 'Duration', duration, durationValue), - field(document, 'Stagger', stagger, staggerValue), - field(document, 'Easing', easing), - field(document, 'Apr + line timing', customTiming), - replay, - ) - return { - root, - duration, - durationValue, - stagger, - staggerValue, - easing, - customTiming, - replay, - } -} - -function field( - document: Document, - label: string, - control: HTMLElement, - value?: HTMLOutputElement, -) { - const root = document.createElement('label') - Object.assign(root.style, { - display: 'inline-flex', - alignItems: 'center', - gap: '6px', - whiteSpace: 'nowrap', - }) - root.append(label, control) - if (value) root.append(value) - return root -} - -function range( - document: Document, - min: number, - max: number, - step: number, - value: number, -) { - const input = document.createElement('input') - input.type = 'range' - input.min = String(min) - input.max = String(max) - input.step = String(step) - input.value = String(value) - input.style.width = '96px' - return input -} - -function button(document: Document, label: string) { - const control = document.createElement('button') - control.type = 'button' - control.textContent = label - control.style.padding = '0 14px' - return control -} +export { catalogCase, motionEntranceDefinition, mount } from './view' +export type { MotionSettings } from './view' diff --git a/benchmarks/conformance/cases/112-motion-entrance/view.tsx b/benchmarks/conformance/cases/112-motion-entrance/view.tsx new file mode 100644 index 00000000..295c9eb4 --- /dev/null +++ b/benchmarks/conformance/cases/112-motion-entrance/view.tsx @@ -0,0 +1,237 @@ +import { + forwardRef, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { barY, defineChart, lineY } from '@tanstack/charts' +import { motion } from '@tanstack/charts/motion' +import { Chart } from '@tanstack/charts/react/core' +import { scaleBand, scaleLinear } from 'd3-scale' +import { readChartMotionState, settleChartMotion } from '../../shared/motion' +import { + ControlBar, + ControlButton, + ControlField, + RangeField, +} from '../../shared/react-controls' +import { reactMount } from '../../shared/react-mount' +import { tanstackCase } from '../../shared/mount' +import { entranceRows as rows } from './model' +import type { ChartMotionTweenTransition } from '@tanstack/charts' +import type { MotionRow } from './model' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +export interface MotionSettings { + duration: number + staggerMs: number + easing: ChartMotionTweenTransition['easing'] + customTiming: boolean +} + +const initialSettings: MotionSettings = { + duration: 1_100, + staggerMs: 55, + easing: undefined, + customTiming: true, +} + +const MotionEntranceExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function MotionEntranceExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const replayRef = useRef(null) + const [settings, setSettings] = useState(initialSettings) + const [replayCount, setReplayCount] = useState(1) + const renderer = useMemo( + () => motion(), + [replayCount], + ) + const definition = useMemo( + () => motionEntranceDefinition(settings), + [settings], + ) + const replay = () => setReplayCount((value) => value + 1) + const changeSettings = (next: Partial) => { + setSettings((current) => ({ ...current, ...next })) + replay() + } + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + if ( + (target.view && target.view !== 'main') || + target.anchor !== 'control:replay' + ) { + return null + } + return replayRef.current ? center(replayRef.current) : null + }, + readState() { + return { + duration: settings.duration, + staggerMs: settings.staggerMs, + customTiming: settings.customTiming, + replayCount, + motionState: viewRef.current + ? readChartMotionState(viewRef.current) + : null, + } + }, + settle: () => + viewRef.current + ? settleChartMotion(viewRef.current, settings.duration * 1.6) + : Promise.resolve(), + }), + [replayCount, settings], + ) + + return ( +
+ + changeSettings({ duration })} + /> + changeSettings({ staggerMs })} + /> + + + + + + changeSettings({ customTiming: event.currentTarget.checked }) + } + /> + + + Replay + + + +
+ ) +}) + +export const mount = reactMount(MotionEntranceExample) + +export function motionEntranceDefinition(settings: MotionSettings) { + const { duration, easing, staggerMs, customTiming } = settings + return defineChart({ + motion: { + transition: { type: 'tween', duration, easing }, + }, + marks: [ + barY(rows, { + x: 'period', + y: 'actual', + key: 'id', + fill: '#7c3aed', + radius: 7, + inset: 4, + motion(context) { + if (customTiming && context.datum?.featured) { + return { + delay: duration * 0.19, + transition: { type: 'tween', duration: duration * 0.64 }, + } + } + return { delay: context.datumIndex * staggerMs } + }, + }), + lineY(rows, { + x: 'period', + y: 'target', + key: 'id', + stroke: '#f97316', + strokeWidth: 3, + motion: customTiming + ? { + delay: duration * 0.1, + transition: { type: 'tween', duration: duration * 0.78 }, + } + : undefined, + }), + ], + x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, + y: { scale: scaleLinear().domain([0, 100]) }, + guides: false, + margin: { top: 20, right: 20, bottom: 20, left: 20 }, + }) +} + +export const catalogCase = tanstackCase( + () => motionEntranceDefinition(initialSettings), + 'Staggered monthly actuals and target', +) + +function readEasing(value: string): MotionSettings['easing'] { + return value === 'linear' || + value === 'ease' || + value === 'ease-in' || + value === 'ease-out' || + value === 'ease-in-out' + ? value + : undefined +} + +function center(element: HTMLElement) { + const bounds = element.getBoundingClientRect() + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + focusElement: element, + } +} diff --git a/benchmarks/conformance/cases/113-motion-updates/tanstack.test.ts b/benchmarks/conformance/cases/113-motion-updates/tanstack.test.ts index 6106bbd3..91f26567 100644 --- a/benchmarks/conformance/cases/113-motion-updates/tanstack.test.ts +++ b/benchmarks/conformance/cases/113-motion-updates/tanstack.test.ts @@ -203,15 +203,22 @@ describe('declarative keyed motion updates', () => { 'utf8', ) const start = source.indexOf('export function motionUpdatesDefinition(') - const end = source.indexOf('function clearTimer', start) + const end = source.indexOf('export function readEasing', start) const definitionSource = source.slice(start, end) - expect(source).toContain("from '../../shared/motion'") - expect(source).toContain('controls.advance.addEventListener') - expect(source).toContain('controls.interrupt.addEventListener') - expect(source).toContain('controls.replay.addEventListener') - expect(source).toContain('setTimeout(() =>') - expect(source).toContain('settleChartMotion(chart') + const view = readFileSync( + resolve( + process.cwd(), + 'benchmarks/conformance/cases/113-motion-updates/view.tsx', + ), + 'utf8', + ) + expect(view).toContain("from '../../shared/motion'") + expect(view).toContain('onClick={advance}') + expect(view).toContain('onClick={interrupt}') + expect(view).toContain('onClick={replay}') + expect(view).toContain('window.setTimeout(() =>') + expect(view).toContain('settleChartMotion(') expect(definitionSource).toContain('defineChart({') expect(definitionSource).toContain("id: 'actual'") expect(definitionSource).toContain("id: 'target'") diff --git a/benchmarks/conformance/cases/113-motion-updates/tanstack.ts b/benchmarks/conformance/cases/113-motion-updates/tanstack.ts index d25b7b99..bd2dbf77 100644 --- a/benchmarks/conformance/cases/113-motion-updates/tanstack.ts +++ b/benchmarks/conformance/cases/113-motion-updates/tanstack.ts @@ -1,22 +1,11 @@ import { barY, defineChart, lineY } from '@tanstack/charts' -import { motion } from '@tanstack/charts/motion' -import { mountChartRenderer } from '@tanstack/charts/renderer' import { scaleBand, scaleLinear } from 'd3-scale' -import { readChartMotionState, settleChartMotion } from '../../shared/motion' import { updateStages as stages } from './model' import { tanstackCase } from '../../shared/mount' -import type { - ChartRenderer, - ChartRendererHost, - ChartRendererHostOptions, -} from '@tanstack/charts' import type { ChartMotionTweenTransition } from '@tanstack/charts/motion' import type { UpdateRow } from './model' -import type { - ConformanceInput, - ConformanceMount, - ConformanceTestDriver, -} from '../../types' + +export { mount } from './view' export interface UpdateSettings { duration: number @@ -27,175 +16,6 @@ export interface UpdateSettings { mass: number } -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let stage = Math.abs(input.revision) % stages.length - let interruptionCount = 0 - let timer: number | undefined - let host: ChartRendererHost | undefined - let renderer: ChartRenderer | undefined - const settings: UpdateSettings = { - duration: 1_100, - easing: undefined, - spring: false, - stiffness: 170, - damping: 14, - mass: 1, - } - let activeTimeout = settings.duration * 1.6 - const view = container.ownerDocument.createElement('div') - const controls = createControls(container.ownerDocument, settings) - const chart = container.ownerDocument.createElement('div') - view.dataset.conformanceView = 'main' - Object.assign(view.style, { - display: 'grid', - gridTemplateRows: 'auto minmax(0, 1fr)', - height: `${input.height}px`, - color: 'CanvasText', - }) - chart.style.minHeight = '0' - view.append(controls.root, chart) - container.append(view) - const chartHeight = () => - Math.max( - 180, - currentInput.height - controls.root.getBoundingClientRect().height, - ) - - const options = (): - ChartRendererHostOptions | undefined => { - if (!renderer) return undefined - return { - definition: motionUpdatesDefinition(stages[stage] ?? stages[0], settings), - renderer, - width: currentInput.width, - height: chartHeight(), - ariaLabel: 'Keyed actuals and targets during interrupted updates', - } - } - const updateChart = () => { - const next = options() - if (next) host?.update(next) - controls.status.value = `Stage ${stage + 1} of ${stages.length}` - } - const rebuild = () => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - host?.destroy() - activeTimeout = settings.spring ? 5_000 : settings.duration * 1.6 - renderer = motion() - const next = options() - if (next) host = mountChartRenderer(chart, next) - controls.durationField.style.display = settings.spring - ? 'none' - : 'inline-flex' - controls.springFields.style.display = settings.spring ? 'contents' : 'none' - controls.physics.value = settings.spring - ? `${springRegime(settings)} · momentum preserved` - : '' - controls.status.value = `Stage ${stage + 1} of ${stages.length}` - } - const advance = () => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - stage = (stage + 1) % stages.length - updateChart() - } - const interrupt = () => { - clearTimer(container.ownerDocument.defaultView, timer) - stage = 1 - updateChart() - controls.status.value = 'Interrupting in 400 ms' - timer = container.ownerDocument.defaultView?.setTimeout(() => { - stage = 2 - interruptionCount += 1 - updateChart() - timer = undefined - }, 400) - } - const replay = () => { - stage = 0 - rebuild() - } - - controls.advance.addEventListener('click', advance) - controls.interrupt.addEventListener('click', interrupt) - controls.replay.addEventListener('click', replay) - controls.duration.addEventListener('input', () => { - controls.durationValue.value = `${controls.duration.value} ms` - }) - controls.duration.addEventListener('change', () => { - settings.duration = Number(controls.duration.value) - rebuild() - }) - controls.easing.addEventListener('change', () => { - settings.spring = controls.easing.value === 'spring' - settings.easing = readEasing(controls.easing.value) - rebuild() - }) - for (const [control, output, key, suffix] of [ - [controls.stiffness, controls.stiffnessValue, 'stiffness', ''], - [controls.damping, controls.dampingValue, 'damping', ''], - [controls.mass, controls.massValue, 'mass', '×'], - ] as const) { - control.addEventListener('input', () => { - output.value = `${control.value}${suffix}` - }) - control.addEventListener('change', () => { - settings[key] = Number(control.value) - rebuild() - }) - } - rebuild() - - const driver: ConformanceTestDriver = { - resolveTarget(target) { - if (target.view && target.view !== 'main') return null - const control = - target.anchor === 'control:update' - ? controls.advance - : target.anchor === 'control:interrupt' - ? controls.interrupt - : target.anchor === 'control:replay' - ? controls.replay - : null - if (!control) return null - const bounds = control.getBoundingClientRect() - return { - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - focusElement: control, - } - }, - readState() { - return { - stage, - interruptionCount, - ids: (stages[stage] ?? stages[0]).map((row) => row.id), - motionState: readChartMotionState(chart), - } - }, - settle: () => settleChartMotion(chart, activeTimeout), - } - - return { - driver, - update(nextInput) { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - currentInput = nextInput - stage = Math.abs(nextInput.revision) % stages.length - view.style.height = `${nextInput.height}px` - updateChart() - }, - destroy() { - clearTimer(container.ownerDocument.defaultView, timer) - host?.destroy() - view.remove() - }, - } -} - export function motionUpdatesDefinition( rows: readonly UpdateRow[], settings: UpdateSettings, @@ -295,11 +115,7 @@ export function motionUpdatesDefinition( }) } -function clearTimer(view: Window | null, timer: number | undefined) { - if (timer !== undefined) view?.clearTimeout(timer) -} - -function readEasing(value: string): UpdateSettings['easing'] { +export function readEasing(value: string): UpdateSettings['easing'] { return value === 'linear' || value === 'ease' || value === 'ease-in' || @@ -309,7 +125,7 @@ function readEasing(value: string): UpdateSettings['easing'] { : undefined } -function springRegime(settings: UpdateSettings) { +export function springRegime(settings: UpdateSettings) { const ratio = settings.damping / (2 * Math.sqrt(settings.stiffness * settings.mass)) if (ratio < 0.99) return 'underdamped' @@ -332,139 +148,3 @@ export const catalogCase = tanstackCase( ), 'Keyed actuals and targets during interrupted updates', ) - -function createControls(document: Document, settings: UpdateSettings) { - const root = document.createElement('div') - root.setAttribute('role', 'group') - root.setAttribute('aria-label', 'Keyed update motion controls') - Object.assign(root.style, { - display: 'flex', - alignItems: 'center', - alignContent: 'center', - flexWrap: 'wrap', - gap: '8px 12px', - padding: '8px 10px', - font: '500 12px/1.2 system-ui, sans-serif', - }) - - const duration = document.createElement('input') - duration.type = 'range' - duration.min = '300' - duration.max = '1800' - duration.step = '100' - duration.value = String(settings.duration) - duration.style.width = '96px' - const durationValue = document.createElement('output') - durationValue.value = `${settings.duration} ms` - const easing = document.createElement('select') - for (const [value, label] of [ - ['polished', 'Tween · Polished'], - ['spring', 'Spring'], - ['ease', 'Tween · Ease'], - ['ease-out', 'Tween · Ease out'], - ['ease-in-out', 'Tween · Ease in/out'], - ['linear', 'Tween · Linear'], - ]) { - const option = document.createElement('option') - option.value = value - option.textContent = label - easing.append(option) - } - const stiffness = range(document, 40, 400, 10, settings.stiffness) - const stiffnessValue = document.createElement('output') - stiffnessValue.value = String(settings.stiffness) - const damping = range(document, 0, 50, 1, settings.damping) - const dampingValue = document.createElement('output') - dampingValue.value = String(settings.damping) - const mass = range(document, 0.25, 3, 0.25, settings.mass) - const massValue = document.createElement('output') - massValue.value = `${settings.mass}×` - const springFields = document.createElement('span') - springFields.style.display = 'none' - springFields.append( - field(document, 'Stiffness', stiffness, stiffnessValue), - field(document, 'Damping', damping, dampingValue), - field(document, 'Mass', mass, massValue), - ) - const physics = document.createElement('output') - physics.style.opacity = '0.7' - const advance = button(document, 'Update') - const interrupt = button(document, 'Interrupt') - const replay = button(document, 'Replay') - const status = document.createElement('output') - status.setAttribute('aria-live', 'polite') - status.style.opacity = '0.7' - const durationField = field(document, 'Duration', duration, durationValue) - root.append( - durationField, - field(document, 'Transition', easing), - springFields, - physics, - advance, - interrupt, - replay, - status, - ) - return { - root, - durationField, - duration, - durationValue, - easing, - stiffness, - stiffnessValue, - damping, - dampingValue, - mass, - massValue, - springFields, - physics, - advance, - interrupt, - replay, - status, - } -} - -function range( - document: Document, - min: number, - max: number, - step: number, - value: number, -) { - const control = document.createElement('input') - control.type = 'range' - control.min = String(min) - control.max = String(max) - control.step = String(step) - control.value = String(value) - control.style.width = '88px' - return control -} - -function field( - document: Document, - label: string, - control: HTMLElement, - value?: HTMLOutputElement, -) { - const root = document.createElement('label') - Object.assign(root.style, { - display: 'inline-flex', - alignItems: 'center', - gap: '6px', - whiteSpace: 'nowrap', - }) - root.append(label, control) - if (value) root.append(value) - return root -} - -function button(document: Document, label: string) { - const control = document.createElement('button') - control.type = 'button' - control.textContent = label - control.style.padding = '0 14px' - return control -} diff --git a/benchmarks/conformance/cases/113-motion-updates/view.tsx b/benchmarks/conformance/cases/113-motion-updates/view.tsx new file mode 100644 index 00000000..7c41d8ed --- /dev/null +++ b/benchmarks/conformance/cases/113-motion-updates/view.tsx @@ -0,0 +1,253 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { motion } from '@tanstack/charts/motion' +import { Chart } from '@tanstack/charts/react/core' +import { readChartMotionState, settleChartMotion } from '../../shared/motion' +import { + ControlBar, + ControlButton, + ControlField, + RangeField, +} from '../../shared/react-controls' +import { reactMount } from '../../shared/react-mount' +import { motionUpdatesDefinition, readEasing, springRegime } from './tanstack' +import { updateStages as stages } from './model' +import type { UpdateRow } from './model' +import type { UpdateSettings } from './tanstack' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const initialSettings: UpdateSettings = { + duration: 1_100, + easing: undefined, + spring: false, + stiffness: 170, + damping: 14, + mass: 1, +} + +const MotionUpdatesExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function MotionUpdatesExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const updateRef = useRef(null) + const interruptRef = useRef(null) + const replayRef = useRef(null) + const timerRef = useRef(undefined) + const [stage, setStage] = useState( + () => Math.abs(input.revision) % stages.length, + ) + const [settings, setSettings] = useState(initialSettings) + const [replayCount, setReplayCount] = useState(1) + const [interruptionCount, setInterruptionCount] = useState(0) + const [announcement, setAnnouncement] = useState('') + const renderer = useMemo( + () => motion(), + [replayCount], + ) + const rows = stages[stage] ?? stages[0] + const definition = useMemo( + () => motionUpdatesDefinition(rows, settings), + [rows, settings], + ) + const clearTimer = () => { + if (timerRef.current !== undefined) window.clearTimeout(timerRef.current) + timerRef.current = undefined + } + const rebuild = (next: Partial) => { + clearTimer() + setSettings((current) => ({ ...current, ...next })) + setReplayCount((value) => value + 1) + setAnnouncement('') + } + const advance = () => { + clearTimer() + setStage((value) => (value + 1) % stages.length) + setAnnouncement('') + } + const interrupt = () => { + clearTimer() + setStage(1) + setAnnouncement('Interrupting in 400 ms') + timerRef.current = window.setTimeout(() => { + setStage(2) + setInterruptionCount((value) => value + 1) + setAnnouncement('') + timerRef.current = undefined + }, 400) + } + const replay = () => { + clearTimer() + setStage(0) + setReplayCount((value) => value + 1) + setAnnouncement('') + } + + useEffect(() => { + clearTimer() + setStage(Math.abs(input.revision) % stages.length) + setAnnouncement('') + }, [input.revision]) + useEffect(() => () => clearTimer(), []) + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + if (target.view && target.view !== 'main') return null + const control = + target.anchor === 'control:update' + ? updateRef.current + : target.anchor === 'control:interrupt' + ? interruptRef.current + : target.anchor === 'control:replay' + ? replayRef.current + : null + return control ? center(control) : null + }, + readState() { + return { + stage, + interruptionCount, + ids: rows.map((row) => row.id), + motionState: viewRef.current + ? readChartMotionState(viewRef.current) + : null, + } + }, + settle: () => + viewRef.current + ? settleChartMotion( + viewRef.current, + settings.spring ? 5_000 : settings.duration * 1.6, + ) + : Promise.resolve(), + }), + [interruptionCount, rows, settings, stage], + ) + + return ( +
+ + {settings.spring ? null : ( + rebuild({ duration })} + /> + )} + + + + {settings.spring ? ( + <> + rebuild({ stiffness })} + /> + rebuild({ damping })} + /> + rebuild({ mass })} + /> + + {springRegime(settings)} · momentum preserved + + + ) : null} + + Update + + + Interrupt + + + Replay + + + {announcement || `Stage ${stage + 1} of ${stages.length}`} + + + +
+ ) +}) + +export const mount = reactMount(MotionUpdatesExample) + +function center(element: HTMLElement) { + const bounds = element.getBoundingClientRect() + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + focusElement: element, + } +} diff --git a/benchmarks/conformance/cases/114-spring-line-motion/tanstack.test.ts b/benchmarks/conformance/cases/114-spring-line-motion/tanstack.test.ts index 1906b759..bbd968ae 100644 --- a/benchmarks/conformance/cases/114-spring-line-motion/tanstack.test.ts +++ b/benchmarks/conformance/cases/114-spring-line-motion/tanstack.test.ts @@ -170,16 +170,23 @@ describe('declarative spring line motion', () => { 'utf8', ) const start = source.indexOf('export function springLineMotionDefinition(') - const end = source.indexOf('function createControls', start) + const end = source.length const definitionSource = source.slice(start, end) - expect(source).toContain("from '../../shared/motion'") - expect(source).toContain('controls.transition.addEventListener') - expect(source).toContain('controls.interrupt.addEventListener') - expect(source).toContain('controls.replay.addEventListener') - expect(source).toContain('setTimeout(() =>') - expect(source).toContain('settleChartMotion(chart') - expect(source).not.toContain('function settleMotion') + const view = readFileSync( + resolve( + process.cwd(), + 'benchmarks/conformance/cases/114-spring-line-motion/view.tsx', + ), + 'utf8', + ) + expect(view).toContain("from '../../shared/motion'") + expect(view).toContain('onChange={(event) =>') + expect(view).toContain('onClick={interrupt}') + expect(view).toContain('onClick={replay}') + expect(view).toContain('window.setTimeout(() =>') + expect(view).toContain('settleChartMotion(') + expect(view).not.toContain('function settleMotion') expect(definitionSource).toContain('defineChart({') expect(definitionSource).toContain("id: 'primary'") expect(definitionSource).toContain("id: 'comparison'") diff --git a/benchmarks/conformance/cases/114-spring-line-motion/tanstack.ts b/benchmarks/conformance/cases/114-spring-line-motion/tanstack.ts index faeee59b..e0c23abf 100644 --- a/benchmarks/conformance/cases/114-spring-line-motion/tanstack.ts +++ b/benchmarks/conformance/cases/114-spring-line-motion/tanstack.ts @@ -1,158 +1,12 @@ import { defineChart, lineY } from '@tanstack/charts' -import { motion } from '@tanstack/charts/motion' -import { mountChartRenderer } from '@tanstack/charts/renderer' import { scaleBand, scaleLinear } from 'd3-scale' -import { readChartMotionState, settleChartMotion } from '../../shared/motion' import { springLineStages } from './model' import { tanstackCase } from '../../shared/mount' -import type { - ChartRenderer, - ChartRendererHost, - ChartRendererHostOptions, -} from '@tanstack/charts' import type { SpringLineRow } from './model' -import type { - ConformanceInput, - ConformanceMount, - ConformanceTestDriver, -} from '../../types' -export type SpringLineTransitionMode = 'spring' | 'tween' - -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let stage = Math.abs(input.revision) % springLineStages.length - let mode: SpringLineTransitionMode = 'spring' - let interruptionCount = 0 - let timer: number | undefined - let host: ChartRendererHost | undefined - let renderer: ChartRenderer | undefined - const view = container.ownerDocument.createElement('div') - const controls = createControls(container.ownerDocument) - const chart = container.ownerDocument.createElement('div') - view.dataset.conformanceView = 'main' - Object.assign(view.style, { - display: 'grid', - gridTemplateRows: 'auto minmax(0, 1fr)', - height: `${input.height}px`, - color: 'CanvasText', - }) - chart.style.minHeight = '0' - view.append(controls.root, chart) - container.append(view) - - const chartHeight = () => - Math.max( - 180, - currentInput.height - controls.root.getBoundingClientRect().height, - ) - const options = (): - ChartRendererHostOptions | undefined => { - if (!renderer) return undefined - return { - definition: springLineMotionDefinition( - springLineStages[stage] ?? springLineStages[0], - mode, - ), - renderer, - width: currentInput.width, - height: chartHeight(), - ariaLabel: 'Primary and comparison series with spring motion', - } - } - const updateChart = () => { - const next = options() - if (next) host?.update(next) - controls.status.value = `Stage ${stage + 1} of ${springLineStages.length}` - } - const rebuild = () => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - host?.destroy() - renderer = motion() - const next = options() - if (next) host = mountChartRenderer(chart, next) - controls.status.value = `Stage ${stage + 1} of ${springLineStages.length}` - } - const update = () => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - stage = (stage + 1) % springLineStages.length - updateChart() - } - const interrupt = () => { - clearTimer(container.ownerDocument.defaultView, timer) - stage = 1 - updateChart() - controls.status.value = 'Reversing in 260 ms' - timer = container.ownerDocument.defaultView?.setTimeout(() => { - stage = 2 - interruptionCount += 1 - updateChart() - timer = undefined - }, 260) - } - const replay = () => { - stage = 0 - rebuild() - } - - controls.transition.addEventListener('change', () => { - mode = controls.transition.value === 'tween' ? 'tween' : 'spring' - replay() - }) - controls.update.addEventListener('click', update) - controls.interrupt.addEventListener('click', interrupt) - controls.replay.addEventListener('click', replay) - rebuild() +export { mount } from './view' - const driver: ConformanceTestDriver = { - resolveTarget(target) { - if (target.view && target.view !== 'main') return null - const control = - target.anchor === 'control:update' - ? controls.update - : target.anchor === 'control:interrupt' - ? controls.interrupt - : target.anchor === 'control:replay' - ? controls.replay - : null - if (!control) return null - const bounds = control.getBoundingClientRect() - return { - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - focusElement: control, - } - }, - readState() { - return { - stage, - mode, - interruptionCount, - motionState: readChartMotionState(chart), - } - }, - settle: () => settleChartMotion(chart, mode === 'spring' ? 5_000 : 1_500), - } - - return { - driver, - update(nextInput: ConformanceInput) { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - currentInput = nextInput - stage = Math.abs(nextInput.revision) % springLineStages.length - view.style.height = `${nextInput.height}px` - updateChart() - }, - destroy() { - clearTimer(container.ownerDocument.defaultView, timer) - host?.destroy() - view.remove() - }, - } -} +export type SpringLineTransitionMode = 'spring' | 'tween' export function springLineMotionDefinition( rows: readonly SpringLineRow[], @@ -212,49 +66,3 @@ export const catalogCase = tanstackCase( ), 'Primary and comparison series with spring motion', ) - -function createControls(document: Document) { - const root = document.createElement('div') - root.setAttribute('role', 'group') - root.setAttribute('aria-label', 'Line motion controls') - Object.assign(root.style, { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexWrap: 'wrap', - gap: '8px', - padding: '10px', - font: '500 12px/1.2 system-ui, sans-serif', - }) - const transition = document.createElement('select') - transition.setAttribute('aria-label', 'Transition') - for (const [value, label] of [ - ['spring', 'Spring'], - ['tween', 'Tween'], - ]) { - const option = document.createElement('option') - option.value = value - option.textContent = label - transition.append(option) - } - const update = button(document, 'Update') - const interrupt = button(document, 'Interrupt') - const replay = button(document, 'Replay') - const status = document.createElement('output') - status.setAttribute('aria-live', 'polite') - status.style.opacity = '0.7' - root.append(transition, update, interrupt, replay, status) - return { root, transition, update, interrupt, replay, status } -} - -function button(document: Document, label: string) { - const control = document.createElement('button') - control.type = 'button' - control.textContent = label - control.style.padding = '0 14px' - return control -} - -function clearTimer(view: Window | null, timer: number | undefined) { - if (timer !== undefined) view?.clearTimeout(timer) -} diff --git a/benchmarks/conformance/cases/114-spring-line-motion/view.tsx b/benchmarks/conformance/cases/114-spring-line-motion/view.tsx new file mode 100644 index 00000000..2932a31f --- /dev/null +++ b/benchmarks/conformance/cases/114-spring-line-motion/view.tsx @@ -0,0 +1,186 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { motion } from '@tanstack/charts/motion' +import { Chart } from '@tanstack/charts/react/core' +import { readChartMotionState, settleChartMotion } from '../../shared/motion' +import { + ControlBar, + ControlButton, + ControlField, +} from '../../shared/react-controls' +import { reactMount } from '../../shared/react-mount' +import { springLineStages } from './model' +import { springLineMotionDefinition } from './tanstack' +import type { SpringLineRow } from './model' +import type { SpringLineTransitionMode } from './tanstack' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const SpringLineMotionExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function SpringLineMotionExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const updateRef = useRef(null) + const interruptRef = useRef(null) + const replayRef = useRef(null) + const timerRef = useRef(undefined) + const [stage, setStage] = useState( + () => Math.abs(input.revision) % springLineStages.length, + ) + const [mode, setMode] = useState('spring') + const [replayCount, setReplayCount] = useState(1) + const [interruptionCount, setInterruptionCount] = useState(0) + const [announcement, setAnnouncement] = useState('') + const renderer = useMemo( + () => motion(), + [replayCount], + ) + const definition = useMemo( + () => + springLineMotionDefinition( + springLineStages[stage] ?? springLineStages[0], + mode, + ), + [mode, stage], + ) + const clearTimer = () => { + if (timerRef.current !== undefined) window.clearTimeout(timerRef.current) + timerRef.current = undefined + } + const update = () => { + clearTimer() + setStage((value) => (value + 1) % springLineStages.length) + setAnnouncement('') + } + const interrupt = () => { + clearTimer() + setStage(1) + setAnnouncement('Reversing in 260 ms') + timerRef.current = window.setTimeout(() => { + setStage(2) + setInterruptionCount((value) => value + 1) + setAnnouncement('') + timerRef.current = undefined + }, 260) + } + const replay = () => { + clearTimer() + setStage(0) + setReplayCount((value) => value + 1) + setAnnouncement('') + } + + useEffect(() => { + clearTimer() + setStage(Math.abs(input.revision) % springLineStages.length) + setAnnouncement('') + }, [input.revision]) + useEffect(() => () => clearTimer(), []) + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + if (target.view && target.view !== 'main') return null + const control = + target.anchor === 'control:update' + ? updateRef.current + : target.anchor === 'control:interrupt' + ? interruptRef.current + : target.anchor === 'control:replay' + ? replayRef.current + : null + return control ? center(control) : null + }, + readState() { + return { + stage, + mode, + interruptionCount, + motionState: viewRef.current + ? readChartMotionState(viewRef.current) + : null, + } + }, + settle: () => + viewRef.current + ? settleChartMotion( + viewRef.current, + mode === 'spring' ? 5_000 : 1_500, + ) + : Promise.resolve(), + }), + [interruptionCount, mode, stage], + ) + + return ( +
+ + + + + + Update + + + Interrupt + + + Replay + + + {announcement || `Stage ${stage + 1} of ${springLineStages.length}`} + + + +
+ ) +}) + +export const mount = reactMount(SpringLineMotionExample) + +function center(element: HTMLElement) { + const bounds = element.getBoundingClientRect() + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + focusElement: element, + } +} diff --git a/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts b/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts index 3b248c15..f2a38436 100644 --- a/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts +++ b/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts @@ -187,17 +187,24 @@ describe('declarative definition-owned motion', () => { 'utf8', ) const start = source.indexOf('export function definitionMotionDefinition(') - const end = source.indexOf('function createControls', start) + const end = source.length const definitionSource = source.slice(start, end) - expect(source).toContain("from '../../shared/motion'") - expect(source).toContain('controls.update.addEventListener') - expect(source).toContain('controls.interrupt.addEventListener') - expect(source).toContain('controls.replay.addEventListener') - expect(source).toContain('setTimeout(() =>') - expect(source).toContain('mountChartRenderer(chart') - expect(source).toContain('settleChartMotion(chart') - expect(source).not.toContain('function settleMotion') + const view = readFileSync( + resolve( + process.cwd(), + 'benchmarks/conformance/cases/115-definition-motion/view.tsx', + ), + 'utf8', + ) + expect(view).toContain("from '../../shared/motion'") + expect(view).toContain('onClick={advance}') + expect(view).toContain('onClick={interrupt}') + expect(view).toContain('onClick={replay}') + expect(view).toContain('window.setTimeout(() =>') + expect(view).toContain('() - -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let stage = Math.abs(input.revision) % definitionMotionStages.length - let interruptionCount = 0 - let timer: number | undefined - let host: ChartRendererHost | undefined - const view = container.ownerDocument.createElement('div') - const controls = createControls(container.ownerDocument) - const chart = container.ownerDocument.createElement('div') - view.dataset.conformanceView = 'main' - Object.assign(view.style, { - display: 'grid', - gridTemplateRows: 'auto minmax(0, 1fr)', - height: `${input.height}px`, - color: 'CanvasText', - }) - chart.style.minHeight = '0' - view.append(controls.root, chart) - container.append(view) - - const chartHeight = () => - Math.max( - 220, - currentInput.height - controls.root.getBoundingClientRect().height, - ) - const options = (): ChartRendererHostOptions< - DefinitionMotionRow, - string, - number - > => ({ - definition: definitionMotionDefinition( - definitionMotionStages[stage] ?? definitionMotionStages[0], - ), - renderer, - width: currentInput.width, - height: chartHeight(), - ariaLabel: 'Definition-owned chart, mark, datum, and guide motion', - }) - const updateChart = () => { - host?.update(options()) - controls.status.value = `Stage ${stage + 1} of ${definitionMotionStages.length}` - } - const advance = () => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - stage = (stage + 1) % definitionMotionStages.length - updateChart() - } - const interrupt = () => { - clearTimer(container.ownerDocument.defaultView, timer) - stage = 1 - updateChart() - controls.status.value = 'Retargeting in 220 ms' - timer = container.ownerDocument.defaultView?.setTimeout(() => { - stage = 2 - interruptionCount += 1 - updateChart() - timer = undefined - }, 220) - } - const replay = () => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - host?.destroy() - stage = 0 - host = mountChartRenderer(chart, options()) - controls.status.value = `Stage 1 of ${definitionMotionStages.length}` - } - - controls.update.addEventListener('click', advance) - controls.interrupt.addEventListener('click', interrupt) - controls.replay.addEventListener('click', replay) - host = mountChartRenderer(chart, options()) - controls.status.value = `Stage ${stage + 1} of ${definitionMotionStages.length}` - - const driver: ConformanceTestDriver = { - resolveTarget(target) { - if (target.view && target.view !== 'main') return null - const control = - target.anchor === 'control:update' - ? controls.update - : target.anchor === 'control:interrupt' - ? controls.interrupt - : target.anchor === 'control:replay' - ? controls.replay - : null - if (!control) return null - const bounds = control.getBoundingClientRect() - return { - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - focusElement: control, - } - }, - readState() { - return { - stage, - interruptionCount, - motionState: readChartMotionState(chart), - } - }, - settle: () => settleChartMotion(chart, 5_000), - } - - return { - driver, - update(nextInput: ConformanceInput) { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - currentInput = nextInput - stage = Math.abs(nextInput.revision) % definitionMotionStages.length - view.style.height = `${nextInput.height}px` - updateChart() - }, - destroy() { - clearTimer(container.ownerDocument.defaultView, timer) - host?.destroy() - view.remove() - }, - } -} +export { mount } from './view' export function definitionMotionDefinition( rows: readonly DefinitionMotionRow[], @@ -236,38 +102,3 @@ export const catalogCase = tanstackCase( true, { guides: true, margin: true }, ) - -function createControls(document: Document) { - const root = document.createElement('div') - root.setAttribute('role', 'group') - root.setAttribute('aria-label', 'Definition motion controls') - Object.assign(root.style, { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexWrap: 'wrap', - gap: '8px', - padding: '10px', - font: '500 12px/1.2 system-ui, sans-serif', - }) - const update = button(document, 'Update') - const interrupt = button(document, 'Interrupt') - const replay = button(document, 'Replay') - const status = document.createElement('output') - status.setAttribute('aria-live', 'polite') - status.style.opacity = '0.7' - root.append(update, interrupt, replay, status) - return { root, update, interrupt, replay, status } -} - -function button(document: Document, label: string) { - const control = document.createElement('button') - control.type = 'button' - control.textContent = label - control.style.padding = '0 14px' - return control -} - -function clearTimer(view: Window | null, timer: number | undefined) { - if (timer !== undefined) view?.clearTimeout(timer) -} diff --git a/benchmarks/conformance/cases/115-definition-motion/view.tsx b/benchmarks/conformance/cases/115-definition-motion/view.tsx new file mode 100644 index 00000000..45b9fd9f --- /dev/null +++ b/benchmarks/conformance/cases/115-definition-motion/view.tsx @@ -0,0 +1,162 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { motion } from '@tanstack/charts/motion' +import { Chart } from '@tanstack/charts/react/core' +import { readChartMotionState, settleChartMotion } from '../../shared/motion' +import { ControlBar, ControlButton } from '../../shared/react-controls' +import { reactMount } from '../../shared/react-mount' +import { definitionMotionStages } from './model' +import { definitionMotionDefinition } from './tanstack' +import type { DefinitionMotionRow } from './model' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const DefinitionMotionExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function DefinitionMotionExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const updateRef = useRef(null) + const interruptRef = useRef(null) + const replayRef = useRef(null) + const timerRef = useRef(undefined) + const [stage, setStage] = useState( + () => Math.abs(input.revision) % definitionMotionStages.length, + ) + const [replayCount, setReplayCount] = useState(1) + const [interruptionCount, setInterruptionCount] = useState(0) + const [announcement, setAnnouncement] = useState('') + const renderer = useMemo( + () => motion(), + [replayCount], + ) + const definition = useMemo( + () => + definitionMotionDefinition( + definitionMotionStages[stage] ?? definitionMotionStages[0], + ), + [stage], + ) + const clearTimer = () => { + if (timerRef.current !== undefined) window.clearTimeout(timerRef.current) + timerRef.current = undefined + } + const advance = () => { + clearTimer() + setStage((value) => (value + 1) % definitionMotionStages.length) + setAnnouncement('') + } + const interrupt = () => { + clearTimer() + setStage(1) + setAnnouncement('Retargeting in 220 ms') + timerRef.current = window.setTimeout(() => { + setStage(2) + setInterruptionCount((value) => value + 1) + setAnnouncement('') + timerRef.current = undefined + }, 220) + } + const replay = () => { + clearTimer() + setStage(0) + setReplayCount((value) => value + 1) + setAnnouncement('') + } + + useEffect(() => { + clearTimer() + setStage(Math.abs(input.revision) % definitionMotionStages.length) + setAnnouncement('') + }, [input.revision]) + useEffect(() => () => clearTimer(), []) + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + if (target.view && target.view !== 'main') return null + const control = + target.anchor === 'control:update' + ? updateRef.current + : target.anchor === 'control:interrupt' + ? interruptRef.current + : target.anchor === 'control:replay' + ? replayRef.current + : null + return control ? center(control) : null + }, + readState() { + return { + stage, + interruptionCount, + motionState: viewRef.current + ? readChartMotionState(viewRef.current) + : null, + } + }, + settle: () => + viewRef.current + ? settleChartMotion(viewRef.current, 5_000) + : Promise.resolve(), + }), + [interruptionCount, stage], + ) + + return ( +
+ + + Update + + + Interrupt + + + Replay + + + {announcement || + `Stage ${stage + 1} of ${definitionMotionStages.length}`} + + + +
+ ) +}) + +export const mount = reactMount(DefinitionMotionExample) + +function center(element: HTMLElement) { + const bounds = element.getBoundingClientRect() + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + focusElement: element, + } +} diff --git a/benchmarks/conformance/cases/116-geometry-morph/tanstack.test.ts b/benchmarks/conformance/cases/116-geometry-morph/tanstack.test.ts index 95241f67..3b3162af 100644 --- a/benchmarks/conformance/cases/116-geometry-morph/tanstack.test.ts +++ b/benchmarks/conformance/cases/116-geometry-morph/tanstack.test.ts @@ -182,13 +182,23 @@ describe('normalized-topology geometry morph', () => { 'function normalizedTopologyMark(', definitionStart, ) - const shellStart = source.indexOf('function createControls', markStart) + const shellStart = source.indexOf( + 'export function modeForRevision', + markStart, + ) const definitionSource = source.slice(definitionStart, markStart) const customSource = source.slice(markStart, shellStart) - expect(source).toContain("from '../../shared/motion'") - expect(source).toContain('settleChartMotion(chart') - expect(source).not.toContain('function settleMotion') + const view = readFileSync( + resolve( + process.cwd(), + 'benchmarks/conformance/cases/116-geometry-morph/view.tsx', + ), + 'utf8', + ) + expect(view).toContain("from '../../shared/motion'") + expect(view).toContain('settleChartMotion(') + expect(view).not.toContain('function settleMotion') expect(source).not.toContain("from 'd3-shape'") expect(source).not.toContain("from 'd3-interpolate'") expect(definitionSource).toContain('defineChart({') diff --git a/benchmarks/conformance/cases/116-geometry-morph/tanstack.ts b/benchmarks/conformance/cases/116-geometry-morph/tanstack.ts index 517f30d4..47725da3 100644 --- a/benchmarks/conformance/cases/116-geometry-morph/tanstack.ts +++ b/benchmarks/conformance/cases/116-geometry-morph/tanstack.ts @@ -1,153 +1,19 @@ import { createMark, defineChart } from '@tanstack/charts' -import { motion } from '@tanstack/charts/motion' -import { mountChartRenderer } from '@tanstack/charts/renderer' import { scaleLinear } from 'd3-scale' -import { readChartMotionState, settleChartMotion } from '../../shared/motion' import { morphData, morphModes } from './model' import { tanstackCase } from '../../shared/mount' import type { ChartMotionDefinition, ChartPoint, - ChartRendererHost, - ChartRendererHostOptions, SceneNode, } from '@tanstack/charts' import type { MorphDatum, MorphMode } from './model' -import type { - ConformanceInput, - ConformanceMount, - ConformanceTestDriver, -} from '../../types' type Point = readonly [number, number] const sampleCount = 48 -const renderer = motion() - -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let mode = modeForRevision(input.revision) - let interruptionCount = 0 - let timer: number | undefined - let host: ChartRendererHost | undefined - const view = container.ownerDocument.createElement('div') - const controls = createControls(container.ownerDocument) - const chart = container.ownerDocument.createElement('div') - view.dataset.conformanceView = 'main' - Object.assign(view.style, { - display: 'grid', - gridTemplateRows: 'auto minmax(0, 1fr)', - height: `${input.height}px`, - color: 'CanvasText', - }) - chart.style.minHeight = '0' - view.append(controls.root, chart) - container.append(view) - - const chartHeight = () => - Math.max( - 220, - currentInput.height - controls.root.getBoundingClientRect().height, - ) - const options = (): ChartRendererHostOptions => ({ - definition: geometryMorphDefinition(morphData, mode), - renderer, - width: currentInput.width, - height: chartHeight(), - ariaLabel: `Data morphing as ${mode}`, - }) - const updateChart = () => { - host?.update(options()) - controls.status.value = modeLabel(mode) - } - const selectMode = (next: MorphMode) => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - mode = next - updateChart() - } - const advance = () => { - const index = morphModes.indexOf(mode) - selectMode(morphModes[(index + 1) % morphModes.length] ?? 'bars') - } - const interrupt = () => { - clearTimer(container.ownerDocument.defaultView, timer) - mode = 'rose' - updateChart() - controls.status.value = 'Rose → bubbles in 180 ms' - timer = container.ownerDocument.defaultView?.setTimeout(() => { - mode = 'bubbles' - interruptionCount += 1 - updateChart() - timer = undefined - }, 180) - } - const replay = () => { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - host?.destroy() - mode = 'bars' - host = mountChartRenderer(chart, options()) - controls.status.value = modeLabel(mode) - } - - for (const [buttonMode, button] of controls.modes) { - button.addEventListener('click', () => selectMode(buttonMode)) - } - controls.next.addEventListener('click', advance) - controls.interrupt.addEventListener('click', interrupt) - controls.replay.addEventListener('click', replay) - host = mountChartRenderer(chart, options()) - controls.status.value = modeLabel(mode) - - const driver: ConformanceTestDriver = { - resolveTarget(target) { - if (target.view && target.view !== 'main') return null - const control = - target.anchor === 'control:update' - ? controls.next - : target.anchor === 'control:interrupt' - ? controls.interrupt - : target.anchor === 'control:replay' - ? controls.replay - : null - if (!control) return null - const bounds = control.getBoundingClientRect() - return { - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - focusElement: control, - } - }, - readState() { - return { - mode, - interruptionCount, - pathCount: chart.querySelectorAll('g.ts-chart__geometry-morph > path') - .length, - motionState: readChartMotionState(chart), - } - }, - settle: () => settleChartMotion(chart, 5_000), - } - return { - driver, - update(nextInput: ConformanceInput) { - clearTimer(container.ownerDocument.defaultView, timer) - timer = undefined - currentInput = nextInput - mode = modeForRevision(nextInput.revision) - view.style.height = `${nextInput.height}px` - updateChart() - }, - destroy() { - clearTimer(container.ownerDocument.defaultView, timer) - host?.destroy() - view.remove() - }, - } -} +export { mount } from './view' export function geometryMorphDefinition( data: readonly MorphDatum[], @@ -414,58 +280,10 @@ function sampleSector( return points } -function createControls(document: Document) { - const root = document.createElement('div') - root.setAttribute('role', 'group') - root.setAttribute('aria-label', 'Geometry morph controls') - Object.assign(root.style, { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexWrap: 'wrap', - gap: '8px', - padding: '10px', - font: '500 12px/1.2 system-ui, sans-serif', - }) - const modes = new Map() - for (const mode of morphModes) { - const control = button(document, modeLabel(mode)) - modes.set(mode, control) - root.append(control) - } - const next = button(document, 'Next') - const interrupt = button(document, 'Interrupt') - const replay = button(document, 'Replay') - const status = document.createElement('output') - status.setAttribute('aria-live', 'polite') - Object.assign(status.style, { - display: 'inline-block', - width: '150px', - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - opacity: '0.7', - }) - root.append(next, interrupt, replay, status) - return { root, modes, next, interrupt, replay, status } -} - -function button(document: Document, label: string) { - const control = document.createElement('button') - control.type = 'button' - control.textContent = label - control.style.padding = '0 12px' - return control -} - -function modeForRevision(revision: number) { +export function modeForRevision(revision: number) { return morphModes[Math.abs(revision) % morphModes.length] ?? 'bars' } -function modeLabel(mode: MorphMode) { +export function modeLabel(mode: MorphMode) { return mode[0]!.toUpperCase() + mode.slice(1) } - -function clearTimer(view: Window | null, timer: number | undefined) { - if (timer !== undefined) view?.clearTimeout(timer) -} diff --git a/benchmarks/conformance/cases/116-geometry-morph/view.tsx b/benchmarks/conformance/cases/116-geometry-morph/view.tsx new file mode 100644 index 00000000..7c10723c --- /dev/null +++ b/benchmarks/conformance/cases/116-geometry-morph/view.tsx @@ -0,0 +1,185 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { motion } from '@tanstack/charts/motion' +import { Chart } from '@tanstack/charts/react/core' +import { readChartMotionState, settleChartMotion } from '../../shared/motion' +import { ControlBar, ControlButton } from '../../shared/react-controls' +import { reactMount } from '../../shared/react-mount' +import { morphData, morphModes } from './model' +import { geometryMorphDefinition, modeForRevision, modeLabel } from './tanstack' +import type { MorphDatum, MorphMode } from './model' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const GeometryMorphExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function GeometryMorphExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const nextRef = useRef(null) + const interruptRef = useRef(null) + const replayRef = useRef(null) + const timerRef = useRef(undefined) + const [mode, setMode] = useState(() => + modeForRevision(input.revision), + ) + const [replayCount, setReplayCount] = useState(1) + const [interruptionCount, setInterruptionCount] = useState(0) + const [announcement, setAnnouncement] = useState('') + const renderer = useMemo( + () => motion(), + [replayCount], + ) + const definition = useMemo( + () => geometryMorphDefinition(morphData, mode), + [mode], + ) + const clearTimer = () => { + if (timerRef.current !== undefined) window.clearTimeout(timerRef.current) + timerRef.current = undefined + } + const selectMode = (nextMode: MorphMode) => { + clearTimer() + setMode(nextMode) + setAnnouncement('') + } + const advance = () => { + const index = morphModes.indexOf(mode) + selectMode(morphModes[(index + 1) % morphModes.length] ?? 'bars') + } + const interrupt = () => { + clearTimer() + setMode('rose') + setAnnouncement('Rose → bubbles in 180 ms') + timerRef.current = window.setTimeout(() => { + setMode('bubbles') + setInterruptionCount((value) => value + 1) + setAnnouncement('') + timerRef.current = undefined + }, 180) + } + const replay = () => { + clearTimer() + setMode('bars') + setReplayCount((value) => value + 1) + setAnnouncement('') + } + + useEffect(() => { + clearTimer() + setMode(modeForRevision(input.revision)) + setAnnouncement('') + }, [input.revision]) + useEffect(() => () => clearTimer(), []) + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + if (target.view && target.view !== 'main') return null + const control = + target.anchor === 'control:update' + ? nextRef.current + : target.anchor === 'control:interrupt' + ? interruptRef.current + : target.anchor === 'control:replay' + ? replayRef.current + : null + return control ? center(control) : null + }, + readState() { + return { + mode, + interruptionCount, + pathCount: + viewRef.current?.querySelectorAll( + 'g.ts-chart__geometry-morph > path', + ).length ?? 0, + motionState: viewRef.current + ? readChartMotionState(viewRef.current) + : null, + } + }, + settle: () => + viewRef.current + ? settleChartMotion(viewRef.current, 5_000) + : Promise.resolve(), + }), + [interruptionCount, mode], + ) + + return ( +
+ + {morphModes.map((candidate) => ( + selectMode(candidate)} + > + {modeLabel(candidate)} + + ))} + + Next + + + Interrupt + + + Replay + + + {announcement || modeLabel(mode)} + + + +
+ ) +}) + +export const mount = reactMount(GeometryMorphExample) + +function center(element: HTMLElement) { + const bounds = element.getBoundingClientRect() + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + focusElement: element, + } +} diff --git a/benchmarks/conformance/cases/117-focus-cursor-motion/tanstack.test.ts b/benchmarks/conformance/cases/117-focus-cursor-motion/tanstack.test.ts index 6fcd3a9d..92d5b1e0 100644 --- a/benchmarks/conformance/cases/117-focus-cursor-motion/tanstack.test.ts +++ b/benchmarks/conformance/cases/117-focus-cursor-motion/tanstack.test.ts @@ -10,6 +10,7 @@ import { type ChartSpecDatum, type SceneNode, } from '@tanstack/charts' +import { act } from 'react' import { describe, expect, expectTypeOf, it } from 'vitest' import { focusMotionRows } from './model' import { catalogCase, focusCursorMotionDefinition, mount } from './tanstack' @@ -125,7 +126,10 @@ describe('definition-owned focus cursor motion', () => { height: 400, revision: 0, } satisfies ConformanceInput - const mounted = mount(container, input) + let mounted!: ReturnType + act(() => { + mounted = mount(container, input) + }) const status = container.querySelector( 'output[aria-live="polite"]', ) @@ -137,17 +141,23 @@ describe('definition-owned focus cursor motion', () => { expect(status.parentElement).not.toBe(svg.parentElement) expect(status.style.display).toBe('') - mounted.update({ ...input, width: 720, revision: 1 }) + act(() => { + mounted.update({ ...input, width: 720, revision: 1 }) + }) expect( container.querySelector('output[aria-live="polite"]'), ).toBe(status) expect(status.isConnected).toBe(true) - svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + act(() => { + svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + }) expect(status.textContent).toMatch(/^Mon · (Alpha|Beta|Gamma) · 3 grouped$/) expect(status.isConnected).toBe(true) - mounted.destroy() + act(() => { + mounted.destroy() + }) expect(status.isConnected).toBe(false) container.remove() }) @@ -189,10 +199,7 @@ describe('definition-owned focus cursor motion', () => { const definitionStart = source.indexOf( 'export function focusCursorMotionDefinition()', ) - const shellStart = source.indexOf( - 'function createFocusStatus', - definitionStart, - ) + const shellStart = source.length const definitionSource = source.slice(definitionStart, shellStart) expect(definitionSource).toContain('crosshair({') @@ -206,7 +213,15 @@ describe('definition-owned focus cursor motion', () => { expect(source).not.toContain('requestAnimationFrame') expect(source).not.toContain('createElementNS') expect(source).not.toContain('CrosshairOverlay') - expect(source).toContain('onRender(context') + const view = readFileSync( + resolve( + process.cwd(), + 'benchmarks/conformance/cases/117-focus-cursor-motion/view.tsx', + ), + 'utf8', + ) + expect(view).toContain('onRender={(context) =>') + expect(view).toContain('({ initial: false }) - -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let scene: ChartScene | undefined - let host: ChartRendererHost | undefined - let focused: readonly ChartPoint[] = [] - const surface = container.ownerDocument.createElement('div') - surface.dataset.conformanceView = 'main' - surface.style.position = 'relative' - surface.style.width = `${input.width}px` - surface.style.height = `${input.height}px` - const chartRoot = container.ownerDocument.createElement('div') - Object.assign(chartRoot.style, { - width: '100%', - height: '100%', - }) - surface.append(chartRoot) - const status = createFocusStatus(surface) - container.append(surface) - - const options = () => ({ - definition: focusCursorMotionDefinition(), - renderer, - width: currentInput.width, - height: currentInput.height, - ariaLabel: 'Grouped line chart with animated focus and crosshair', - ariaDescription: - 'Move across the chart or use the arrow keys. The nearest point, shared period, focused series, and remaining marks animate separately.', - onFocusGroupChange( - points: readonly ChartPoint[], - ) { - focused = points - paintStatus(status, points) - }, - onRender(context: { scene: ChartScene }) { - scene = context.scene - }, - }) - - host = mountChartRenderer(chartRoot, options()) - scene = host.getScene() - - const driver: ConformanceTestDriver = { - resolveTarget(target) { - if (target.view && target.view !== 'main') return null - const period = target.anchor.startsWith('period:') - ? target.anchor.slice('period:'.length) - : focusMotionPeriods[Number(target.anchor.split(':').at(-1))] - const point = scene?.points.find( - (candidate) => - candidate.datum.period === period && - candidate.datum.series === focusMotionSeries[0], - ) - const svg = surface.querySelector('svg.ts-chart') - if (!point || !scene || !svg) return null - const bounds = svg.getBoundingClientRect() - return { - x: bounds.left + (point.x / scene.width) * bounds.width, - y: bounds.top + (point.y / scene.height) * bounds.height, - focusElement: svg, - } - }, - readState() { - const primary = focused[0] - const layer = surface.querySelector( - '[data-ts-focus-guide-layer="over"]', - ) - const xRule = layer?.querySelector( - '[data-ts-key="focus-motion-crosshair:x-rule"]', - ) - const marker = layer?.querySelector( - '[data-ts-key="focus-motion-crosshair:marker"]', - ) - const xLabel = layer?.querySelector( - '[data-ts-key="focus-motion-crosshair:x-label:text"]', - ) - const yLabel = layer?.querySelector( - '[data-ts-key="focus-motion-crosshair:y-label:text"]', - ) - const crosshairX = Number(xRule?.getAttribute('x1')) - const crosshairY = Number(marker?.getAttribute('cy')) - return { - focused: primary?.datum.id ?? null, - groupSize: focused.length, - crosshairVisible: - layer?.getAttribute('visibility') !== 'hidden' && Boolean(xRule), - crosshairX, - crosshairY, - crosshairXLabel: xLabel?.textContent ?? '', - crosshairYLabel: yLabel?.textContent ?? '', - crosshairFinite: - Number.isFinite(crosshairX) && Number.isFinite(crosshairY), - crosshairSettled: - Boolean(primary) && - Math.abs(crosshairX - (primary?.x ?? 0)) < 0.1 && - Math.abs(crosshairY - (primary?.y ?? 0)) < 0.1, - focusMotionState: readChartMotionState(surface), - } - }, - } - - return { - driver, - update(nextInput: ConformanceInput) { - currentInput = nextInput - surface.style.width = `${nextInput.width}px` - surface.style.height = `${nextInput.height}px` - host?.update(options()) - scene = host?.getScene() - }, - destroy() { - status.remove() - host?.destroy() - surface.remove() - }, - } -} +export { mount } from './view' export function focusCursorMotionDefinition() { return defineChart({ @@ -268,36 +137,3 @@ export const catalogCase = tanstackCase( }, }, ) - -function createFocusStatus(surface: HTMLElement) { - const status = surface.ownerDocument.createElement('output') - status.setAttribute('aria-live', 'polite') - status.textContent = 'Hover or use ← →' - Object.assign(status.style, { - position: 'absolute', - top: '4px', - left: '50%', - zIndex: '2', - width: '180px', - marginLeft: '-90px', - overflow: 'hidden', - color: 'CanvasText', - font: '600 10px/1.4 system-ui, sans-serif', - textAlign: 'center', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - pointerEvents: 'none', - }) - surface.append(status) - return status -} - -function paintStatus( - status: HTMLOutputElement, - points: readonly ChartPoint[], -) { - const primary = points[0] - status.textContent = primary - ? `${primary.datum.period} · ${primary.datum.series} · ${points.length} grouped` - : 'Hover or use ← →' -} diff --git a/benchmarks/conformance/cases/117-focus-cursor-motion/view.tsx b/benchmarks/conformance/cases/117-focus-cursor-motion/view.tsx new file mode 100644 index 00000000..f65a7983 --- /dev/null +++ b/benchmarks/conformance/cases/117-focus-cursor-motion/view.tsx @@ -0,0 +1,145 @@ +import { + forwardRef, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { motion } from '@tanstack/charts/motion' +import { Chart } from '@tanstack/charts/react/core' +import { readChartMotionState } from '../../shared/motion' +import { reactMount } from '../../shared/react-mount' +import { focusMotionPeriods, focusMotionSeries } from './model' +import { focusCursorMotionDefinition } from './tanstack' +import type { ChartPoint, ChartScene } from '@tanstack/charts' +import type { FocusMotionRow } from './model' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const FocusCursorMotionExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function FocusCursorMotionExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const sceneRef = useRef>(null) + const [focused, setFocused] = useState< + readonly ChartPoint[] + >([]) + const renderer = useMemo( + () => motion({ initial: false }), + [], + ) + const definition = useMemo(focusCursorMotionDefinition, []) + const primary = focused[0] + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + if (target.view && target.view !== 'main') return null + const scene = sceneRef.current + const surface = viewRef.current + const period = target.anchor.startsWith('period:') + ? target.anchor.slice('period:'.length) + : focusMotionPeriods[Number(target.anchor.split(':').at(-1))] + const point = scene?.points.find( + (candidate) => + candidate.datum.period === period && + candidate.datum.series === focusMotionSeries[0], + ) + const svg = surface?.querySelector('svg.ts-chart') + if (!point || !scene || !svg) return null + const bounds = svg.getBoundingClientRect() + return { + x: bounds.left + (point.x / scene.width) * bounds.width, + y: bounds.top + (point.y / scene.height) * bounds.height, + focusElement: svg, + } + }, + readState() { + const surface = viewRef.current + const layer = surface?.querySelector( + '[data-ts-focus-guide-layer="over"]', + ) + const xRule = layer?.querySelector( + '[data-ts-key="focus-motion-crosshair:x-rule"]', + ) + const marker = layer?.querySelector( + '[data-ts-key="focus-motion-crosshair:marker"]', + ) + const xLabel = layer?.querySelector( + '[data-ts-key="focus-motion-crosshair:x-label:text"]', + ) + const yLabel = layer?.querySelector( + '[data-ts-key="focus-motion-crosshair:y-label:text"]', + ) + const crosshairX = Number(xRule?.getAttribute('x1')) + const crosshairY = Number(marker?.getAttribute('cy')) + return { + focused: primary?.datum.id ?? null, + groupSize: focused.length, + crosshairVisible: + layer?.getAttribute('visibility') !== 'hidden' && Boolean(xRule), + crosshairX, + crosshairY, + crosshairXLabel: xLabel?.textContent ?? '', + crosshairYLabel: yLabel?.textContent ?? '', + crosshairFinite: + Number.isFinite(crosshairX) && Number.isFinite(crosshairY), + crosshairSettled: + Boolean(primary) && + Math.abs(crosshairX - (primary?.x ?? 0)) < 0.1 && + Math.abs(crosshairY - (primary?.y ?? 0)) < 0.1, + focusMotionState: surface ? readChartMotionState(surface) : null, + } + }, + }), + [focused, primary], + ) + + return ( +
+ { + sceneRef.current = context.scene + }} + /> + + {primary + ? `${primary.datum.period} · ${primary.datum.series} · ${focused.length} grouped` + : 'Hover or use ← →'} + +
+ ) +}) + +export const mount = reactMount(FocusCursorMotionExample) diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/shell.test.ts b/benchmarks/conformance/cases/118-token-usage-calendar/shell.test.ts index 11dbbf86..a6874b64 100644 --- a/benchmarks/conformance/cases/118-token-usage-calendar/shell.test.ts +++ b/benchmarks/conformance/cases/118-token-usage-calendar/shell.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { act } from 'react' import { calendarChartHeight } from './layout' import { withTokenActivityShell } from './shell' import type { ConformanceInput, ConformanceMount } from '../../types' @@ -28,11 +29,14 @@ describe('token activity shell', () => { const container = document.createElement('div') container.style.minHeight = '480px' const mount = withTokenActivityShell(mountChart) - const handle = mount(container, { - width: 320, - height: 180, - revision: 0, - interactive: true, + let handle!: ReturnType + act(() => { + handle = mount(container, { + width: 320, + height: 180, + revision: 0, + interactive: true, + }) }) const labels = container.querySelectorAll('text') const shell = container.querySelector('.token-activity-shell') @@ -42,7 +46,7 @@ describe('token activity shell', () => { expect(inputs[0]?.height).toBe(calendarChartHeight(320)) expect(shell?.style.width).toBe('100%') expect(shell?.style.height).toBe(`${calendarChartHeight(320)}px`) - expect(container.style.minHeight).toBe(`${calendarChartHeight(320)}px`) + expect(container.style.minHeight).toBe('480px') expect(labels[0]?.getAttribute('x')).toBe('14') expect(labels[0]?.getAttribute('text-anchor')).toBe('middle') expect(labels[1]?.getAttribute('x')).toBe('92') @@ -51,19 +55,23 @@ describe('token activity shell', () => { '.ts-chart__axes', ) - handle.update({ - width: 960, - height: 240, - revision: 1, - interactive: true, + act(() => { + handle.update({ + width: 960, + height: 240, + revision: 1, + interactive: true, + }) }) expect(inputs[1]?.interactive).toBe(true) expect(inputs[1]?.width).toBe(960) expect(inputs[1]?.height).toBe(calendarChartHeight(960)) expect(shell?.style.height).toBe(`${calendarChartHeight(960)}px`) - expect(container.style.minHeight).toBe(`${calendarChartHeight(960)}px`) + expect(container.style.minHeight).toBe('480px') - handle.destroy() + act(() => { + handle.destroy() + }) expect(container.style.minHeight).toBe('480px') }) }) diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/shell.ts b/benchmarks/conformance/cases/118-token-usage-calendar/shell.ts deleted file mode 100644 index b80ed431..00000000 --- a/benchmarks/conformance/cases/118-token-usage-calendar/shell.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { - ConformanceInput, - ConformanceMount, - ConformanceTestDriver, -} from '../../types' -import { calendarChartHeight } from './layout' -import { - formatTokenUsage, - tokenUsageCalendar, - type TokenUsageDay, -} from './model' - -export function withTokenActivityShell( - mountChart: ConformanceMount, -): ConformanceMount { - return (container, input) => { - let currentInput = input - let targetDateKey: string | null = null - const previousMinHeight = container.style.minHeight - const document = container.ownerDocument - const shell = document.createElement('div') - const chartSurface = document.createElement('div') - const style = document.createElement('style') - - shell.className = 'token-activity-shell' - shell.style.boxSizing = 'border-box' - shell.style.width = '100%' - shell.style.overflow = 'hidden' - - chartSurface.style.minHeight = '0' - chartSurface.style.width = '100%' - style.textContent = ` - .ts-chart-tooltip.token-activity-tooltip { - max-width: calc(100% - 24px) !important; - padding: 6px 9px !important; - border: 1px solid #e5e7eb !important; - border-radius: 9px !important; - background: #fff !important; - color: #202124 !important; - box-shadow: none !important; - font: 500 12px/1.25 system-ui, sans-serif !important; - overflow-wrap: normal !important; - white-space: nowrap !important; - } - ` - shell.append(style, chartSurface) - container.append(shell) - - resizeShell(container, shell, chartSurface, input) - const chart = mountChart(chartSurface, chartInput(input)) - - const driver: ConformanceTestDriver = { - resolveTarget(target) { - targetDateKey = null - if (target.view && target.view !== 'main') return null - const dateKey = target.anchor.startsWith('date:') - ? target.anchor.slice('date:'.length) - : null - const days = tokenUsageCalendar(currentInput.revision) - const index = days.findIndex((day) => day.dateKey === dateKey) - const cell = calendarCells(chartSurface, days.length)[index] - if (!cell) return null - targetDateKey = days[index]?.dateKey ?? null - const bounds = cell.getBoundingClientRect() - const chart = chartSurface.querySelector('svg') - const resolved = { - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - } - return chart ? { ...resolved, focusElement: chart } : resolved - }, - readState() { - const days = tokenUsageCalendar(currentInput.revision) - if (chartSurface.querySelector('svg.ts-chart')) { - return tanstackInteractionState(document, days, targetDateKey) - } - const cells = calendarCells(chartSurface, days.length) - const index = cells.findIndex((cell) => cell.matches(':hover')) - const day = days[index] - return interactionState(day) - }, - } - - return { - driver, - update(nextInput) { - targetDateKey = null - currentInput = nextInput - resizeShell(container, shell, chartSurface, nextInput) - chart.update(chartInput(nextInput)) - }, - destroy() { - chart.destroy() - shell.remove() - container.style.minHeight = previousMinHeight - }, - } - } -} - -function chartInput(input: ConformanceInput): ConformanceInput { - return { - ...input, - height: calendarChartHeight(input.width), - } -} - -function resizeShell( - container: HTMLElement, - shell: HTMLElement, - chartSurface: HTMLElement, - input: ConformanceInput, -) { - const sceneHeight = calendarChartHeight(input.width) - const height = input.behavior ? sceneHeight + 120 : sceneHeight - container.style.minHeight = `${height}px` - shell.style.height = `${height}px` - chartSurface.style.height = `${height}px` -} - -function calendarCells( - chartSurface: HTMLElement, - expectedCount: number, -): SVGRectElement[] { - const tanstack = [ - ...chartSurface.querySelectorAll( - 'rect[data-ts-key^="rect-0:"]', - ), - ] - if (tanstack.length === expectedCount) return tanstack - const plot = [ - ...chartSurface.querySelectorAll('.token-usage-cells rect'), - ] - return plot.length === expectedCount ? plot : [] -} - -function tanstackInteractionState( - document: Document, - days: readonly TokenUsageDay[], - targetDateKey: string | null, -) { - const tooltip = document.querySelector('.ts-chart-tooltip') - const bounds = tooltip?.getBoundingClientRect() - const style = tooltip && document.defaultView?.getComputedStyle(tooltip) - const visible = Boolean( - tooltip && - !tooltip.hidden && - bounds && - bounds.width > 0 && - bounds.height > 0 && - style?.display !== 'none' && - style?.visibility !== 'hidden' && - style?.opacity !== '0', - ) - const text = visible ? (tooltip?.textContent?.trim() ?? '') : '' - const targetDay = days.find((day) => day.dateKey === targetDateKey) - const day = - visible && targetDay && text.includes(formatTokenUsage(targetDay)) - ? targetDay - : undefined - return interactionState(day, text) -} - -function interactionState(day?: TokenUsageDay, text = '') { - return { - focus: { date: day?.dateKey ?? null }, - tooltip: { - visible: Boolean(day), - text: text || (day ? formatTokenUsage(day) : ''), - }, - } -} diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/shell.tsx b/benchmarks/conformance/cases/118-token-usage-calendar/shell.tsx new file mode 100644 index 00000000..278d0861 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/shell.tsx @@ -0,0 +1,189 @@ +import { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from 'react' +import { reactMount } from '../../shared/react-mount' +import { calendarChartHeight } from './layout' +import { + formatTokenUsage, + tokenUsageCalendar, + type TokenUsageDay, +} from './model' +import type { + ConformanceHandle, + ConformanceInput, + ConformanceMount, + ConformanceTestDriver, +} from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +export function withTokenActivityShell( + mountChart: ConformanceMount, +): ConformanceMount { + const TokenActivityShell = forwardRef< + ConformanceTestDriver, + ReactConformanceProps + >(function TokenActivityShell({ input }, ref) { + const chartSurfaceRef = useRef(null) + const chartRef = useRef(null) + const inputRef = useRef(input) + const renderedInputRef = useRef(null) + const targetDateKeyRef = useRef(null) + inputRef.current = input + const sceneHeight = calendarChartHeight(input.width) + const height = input.behavior ? sceneHeight + 120 : sceneHeight + + useLayoutEffect(() => { + const chartSurface = chartSurfaceRef.current + if (!chartSurface) return + const chart = mountChart(chartSurface, chartInput(inputRef.current)) + chartRef.current = chart + renderedInputRef.current = inputRef.current + return () => { + chartRef.current = null + chart.destroy() + } + }, []) + + useLayoutEffect(() => { + if (renderedInputRef.current === input) return + targetDateKeyRef.current = null + chartRef.current?.update(chartInput(input)) + renderedInputRef.current = input + }, [input]) + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + targetDateKeyRef.current = null + const chartSurface = chartSurfaceRef.current + if (!chartSurface || (target.view && target.view !== 'main')) { + return null + } + const dateKey = target.anchor.startsWith('date:') + ? target.anchor.slice('date:'.length) + : null + const days = tokenUsageCalendar(inputRef.current.revision) + const index = days.findIndex((day) => day.dateKey === dateKey) + const cell = calendarCells(chartSurface, days.length)[index] + if (!cell) return null + targetDateKeyRef.current = days[index]?.dateKey ?? null + const bounds = cell.getBoundingClientRect() + const chart = chartSurface.querySelector('svg') + const resolved = { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + } + return chart ? { ...resolved, focusElement: chart } : resolved + }, + readState() { + const chartSurface = chartSurfaceRef.current + if (!chartSurface) return interactionState() + const days = tokenUsageCalendar(inputRef.current.revision) + if (chartSurface.querySelector('svg.ts-chart')) { + return tanstackInteractionState( + chartSurface.ownerDocument, + days, + targetDateKeyRef.current, + ) + } + const cells = calendarCells(chartSurface, days.length) + const index = cells.findIndex((cell) => cell.matches(':hover')) + return interactionState(days[index]) + }, + }), + [], + ) + + return ( +
+ +
+
+ ) + }) + + return reactMount(TokenActivityShell) +} + +function chartInput(input: ConformanceInput): ConformanceInput { + return { + ...input, + height: calendarChartHeight(input.width), + } +} + +function calendarCells( + chartSurface: HTMLElement, + expectedCount: number, +): SVGRectElement[] { + const tanstack = [ + ...chartSurface.querySelectorAll( + 'rect[data-ts-key^="rect-0:"]', + ), + ] + if (tanstack.length === expectedCount) return tanstack + const plot = [ + ...chartSurface.querySelectorAll('.token-usage-cells rect'), + ] + return plot.length === expectedCount ? plot : [] +} + +function tanstackInteractionState( + document: Document, + days: readonly TokenUsageDay[], + targetDateKey: string | null, +) { + const tooltip = document.querySelector('.ts-chart-tooltip') + const bounds = tooltip?.getBoundingClientRect() + const style = tooltip && document.defaultView?.getComputedStyle(tooltip) + const visible = Boolean( + tooltip && + !tooltip.hidden && + bounds && + bounds.width > 0 && + bounds.height > 0 && + style?.display !== 'none' && + style?.visibility !== 'hidden' && + style?.opacity !== '0', + ) + const text = visible ? (tooltip?.textContent?.trim() ?? '') : '' + const targetDay = days.find((day) => day.dateKey === targetDateKey) + const day = + visible && targetDay && text.includes(formatTokenUsage(targetDay)) + ? targetDay + : undefined + return interactionState(day, text) +} + +function interactionState(day?: TokenUsageDay, text = '') { + return { + focus: { date: day?.dateKey ?? null }, + tooltip: { + visible: Boolean(day), + text: text || (day ? formatTokenUsage(day) : ''), + }, + } +} diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/tanstack.test.ts b/benchmarks/conformance/cases/118-token-usage-calendar/tanstack.test.ts index 4a36f55d..b4124c67 100644 --- a/benchmarks/conformance/cases/118-token-usage-calendar/tanstack.test.ts +++ b/benchmarks/conformance/cases/118-token-usage-calendar/tanstack.test.ts @@ -110,7 +110,7 @@ describe('definition-owned token activity calendar', () => { const shellSource = readFileSync( resolve( process.cwd(), - 'benchmarks/conformance/cases/118-token-usage-calendar/shell.ts', + 'benchmarks/conformance/cases/118-token-usage-calendar/shell.tsx', ), 'utf8', ) diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/layout.ts b/benchmarks/conformance/cases/85-scrollable-resource-lanes/layout.ts new file mode 100644 index 00000000..7b49da26 --- /dev/null +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/layout.ts @@ -0,0 +1,24 @@ +export const timelineMargin = { + top: 18, + right: 24, + bottom: 50, + left: 12, +} as const + +const headerHeight = 42 + +export function timelineBodyHeight(height: number) { + return Math.max(220, height - headerHeight) +} + +export function timelineContentWidth(viewportWidth: number) { + return Math.max(960, viewportWidth * 2) +} + +export function timelineChartHeight(viewportHeight: number) { + return Math.max(240, viewportHeight - 16) +} + +export function timelineLaneRailWidth(width: number) { + return Math.round(Math.max(96, Math.min(128, width * 0.28))) +} diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/shell.ts b/benchmarks/conformance/cases/85-scrollable-resource-lanes/shell.ts index 27a03478..32f43a7c 100644 --- a/benchmarks/conformance/cases/85-scrollable-resource-lanes/shell.ts +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/shell.ts @@ -1,14 +1,22 @@ import { timelineStatusColors } from './colors' import { resourceLanes, timelineStatuses } from './scenario' +import { + timelineBodyHeight, + timelineChartHeight, + timelineContentWidth, + timelineLaneRailWidth, + timelineMargin, +} from './layout' import type { ConformanceInput } from '../../types' import type { ResourceLane, ResourceTask } from './scenario' -export const timelineMargin = { - top: 18, - right: 24, - bottom: 50, - left: 12, -} as const +export { + timelineBodyHeight, + timelineChartHeight, + timelineContentWidth, + timelineLaneRailWidth, + timelineMargin, +} from './layout' const headerHeight = 42 const focusScrollPadding = 32 @@ -120,22 +128,6 @@ export function sizeResourceTimelineShell( renderSchedule(shell.schedule, rows) } -export function timelineBodyHeight(height: number) { - return Math.max(220, height - headerHeight) -} - -export function timelineContentWidth(viewportWidth: number) { - return Math.max(960, viewportWidth * 2) -} - -export function timelineChartHeight(viewportHeight: number) { - return Math.max(240, viewportHeight - 16) -} - -export function timelineLaneRailWidth(width: number) { - return Math.round(Math.max(96, Math.min(128, width * 0.28))) -} - export function updateTimelineTaskDetails( shell: ResourceTimelineShell, task: ResourceTask | null, diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.test.ts b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.test.ts index 77a1e7d4..8873bad8 100644 --- a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.test.ts +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.test.ts @@ -58,11 +58,11 @@ describe('native scrollable resource timeline geometry', () => { 'benchmarks/conformance/cases/85-scrollable-resource-lanes', ) const shell = readFileSync(resolve(directory, 'shell.ts'), 'utf8') - const source = readFileSync(resolve(directory, 'tanstack.ts'), 'utf8') + const source = readFileSync(resolve(directory, 'view.tsx'), 'utf8') expect(shell).not.toContain("from 'd3-scale'") expect(source).toContain('scene.scales.y.map(lane)') - expect(source).toContain('onFocusChange: updateFocusedTask') + expect(source).toContain('onFocusChange={updateFocusedTask}') expect(source).not.toContain('onFocusGroupChange') }) }) diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts index ce61da8e..262c4716 100644 --- a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts @@ -1,50 +1,20 @@ -import { defineChart, mountChart, rect } from '@tanstack/charts' +import { defineChart, rect } from '@tanstack/charts' import { tooltip } from '@tanstack/charts/tooltip' import { scaleBand, scaleUtc } from 'd3-scale' import { timelineStatusColors } from './colors' -import { - createResourceTimelineShell, - ensureTimelineFocusVisible, - renderTimelineLaneRail, - sizeResourceTimelineShell, - timelineBodyHeight, - timelineChartHeight, - timelineContentWidth, - timelineLaneRailWidth, - timelineMargin, - updateTimelineTaskDetails, -} from './shell' +import { timelineMargin } from './layout' import { resourceLanes, resourceTasks, resourceTimelineDomain, timelineStatuses, } from './scenario' -import { timelineDateKey } from './model' import { tanstackCase } from '../../shared/mount' -import type { - ChartHost, - ChartPoint, - ChartScene, - ChartHostOptions, -} from '@tanstack/charts' -import type { ResourceTask } from './scenario' -import type { - ConformanceGeometryQuery, - ConformanceGeometrySample, - ConformanceInput, - ConformanceMount, - ConformanceTarget, - ConformanceTestDriver, -} from '../../types' +import type { ConformanceInput } from '../../types' const taskInset = 5 -interface TimelineFocusState { - taskId: string | null - centerX: number | null - scrolled: boolean -} +export { mount } from './view' export const resourceTimelineDefinition = (input: ConformanceInput) => { const rows = resourceTasks(input.revision) @@ -54,6 +24,7 @@ export const resourceTimelineDefinition = (input: ConformanceInput) => { return { marks: [ rect(rows, { + key: 'id', x1: 'start', x2: 'end', y: 'resource', @@ -113,240 +84,6 @@ export const catalogCase = tanstackCase( }, ) -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - const shell = createResourceTimelineShell( - container.ownerDocument, - input, - resourceTasks(input.revision), - ) - container.append(shell.root) - const { viewport, chartSurface } = shell - const focusState: TimelineFocusState = { - taskId: null, - centerX: null, - scrolled: false, - } - - const updateFocusedTask = (point: ChartPoint | null) => { - focusState.taskId = point?.datum.id ?? null - focusState.centerX = point?.x ?? null - focusState.scrolled = point - ? ensureTimelineFocusVisible(viewport, point.x) - : false - updateTimelineTaskDetails(shell, point?.datum ?? null) - } - - const chartOptions = ( - nextInput: ConformanceInput, - ): ChartHostOptions => ({ - definition: resourceTimelineDefinition(nextInput), - width: timelineContentWidth( - nextInput.width - timelineLaneRailWidth(nextInput.width), - ), - height: timelineChartHeight(timelineBodyHeight(nextInput.height)), - ariaLabel: 'Tasks scheduled across five resource lanes', - ariaDescription: - 'Focus the chart and use the arrow, Home, and End keys to inspect tasks. Offscreen tasks scroll into view.', - onFocusChange: updateFocusedTask, - onRender: ({ scene }) => { - renderTimelineLaneRail(shell.laneRail, (lane) => scene.scales.y.map(lane)) - }, - }) - const host = mountChart(chartSurface, chartOptions(input)) - const driver = createDriver( - viewport, - chartSurface, - () => currentInput, - host, - focusState, - ) - - return { - driver, - update(nextInput) { - const scrollLeft = viewport.scrollLeft - currentInput = nextInput - sizeResourceTimelineShell( - shell, - nextInput, - resourceTasks(nextInput.revision), - ) - host.update(chartOptions(nextInput)) - viewport.scrollLeft = Math.min( - scrollLeft, - Math.max(0, viewport.scrollWidth - viewport.clientWidth), - ) - }, - destroy() { - host.destroy() - shell.root.remove() - }, - } -} - -function createDriver( - viewport: HTMLDivElement, - chartSurface: HTMLDivElement, - getInput: () => ConformanceInput, - host: ChartHost, - focusState: TimelineFocusState, -): ConformanceTestDriver { - return { - resolveTarget(target) { - return timelineTarget(viewport, chartSurface, host, target) - }, - readState() { - return timelineState(viewport, getInput(), focusState) - }, - geometry(query) { - return timelineGeometry( - viewport, - chartSurface, - getInput(), - host.getScene(), - query, - ) - }, - } -} - -function timelineTarget( - viewport: HTMLDivElement, - chartSurface: HTMLDivElement, - host: ChartHost, - target: ConformanceTarget, -) { - if (target.view !== undefined && target.view !== 'main') { - return null - } - if (target.anchor.startsWith('task:')) { - const taskId = target.anchor.slice('task:'.length) - const scene = host.getScene() - const point = scene.points.find( - (candidate) => candidate.datum.id === taskId, - ) - const svg = chartSurface.querySelector('svg.ts-chart') - if (!point || !svg) return null - const bounds = svg.getBoundingClientRect() - return { - x: bounds.left + (point.x / scene.width) * bounds.width, - y: bounds.top + (point.y / scene.height) * bounds.height, - focusElement: svg, - } - } - if (target.anchor !== 'viewport') return null - const bounds = viewport.getBoundingClientRect() - return { - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - focusElement: viewport, - } -} - -function timelineState( - viewport: HTMLDivElement, - input: ConformanceInput, - focusState: TimelineFocusState, -) { - const rows = resourceTasks(input.revision) - const apiBuild = rows.find((row) => row.id === 'api-build') - const qualityRelease = rows.find((row) => row.id === 'quality-release') - return { - viewport: { - scrollLeft: viewport.scrollLeft, - clientWidth: viewport.clientWidth, - scrollWidth: viewport.scrollWidth, - }, - lanes: { - count: resourceLanes.length, - names: resourceLanes, - }, - tasks: { - count: rows.length, - ids: rows.map((row) => row.id), - apiBuildEnd: apiBuild ? timelineDateKey(apiBuild.end) : null, - qualityReleaseStart: qualityRelease - ? timelineDateKey(qualityRelease.start) - : null, - }, - domain: { - start: timelineDateKey(resourceTimelineDomain[0]), - end: timelineDateKey(resourceTimelineDomain[1]), - }, - focus: { - taskId: focusState.taskId, - visible: - focusState.centerX !== null && - focusState.centerX >= viewport.scrollLeft && - focusState.centerX <= viewport.scrollLeft + viewport.clientWidth, - scrolled: focusState.scrolled, - }, - } -} - -function timelineGeometry( - viewport: HTMLDivElement, - chartSurface: HTMLDivElement, - input: ConformanceInput, - scene: ChartScene, - query: ConformanceGeometryQuery, -): readonly ConformanceGeometrySample[] { - if ( - (query.view !== undefined && query.view !== 'main') || - query.role !== 'rect' - ) { - return [] - } - const svg = chartSurface.querySelector('svg.ts-chart') - if (!svg) return [] - const svgBounds = svg.getBoundingClientRect() - const viewportBounds = viewport.getBoundingClientRect() - const scaleX = svgBounds.width / scene.width - const scaleY = svgBounds.height / scene.height - const height = Math.max( - 0, - (scene.scales.y.bandwidth - taskInset * 2) * scaleY, - ) - - return resourceTasks(input.revision).flatMap((row) => { - const x1 = scene.scales.x.map(row.start) - const x2 = scene.scales.x.map(row.end) - const centerY = scene.scales.y.map(row.resource) - const sample = clipClientSample( - { - x: svgBounds.left + Math.min(x1, x2) * scaleX, - y: - svgBounds.top + - (centerY - scene.scales.y.bandwidth / 2 + taskInset) * scaleY, - width: Math.abs(x2 - x1) * scaleX, - height, - paint: timelineStatusColors[row.status], - }, - viewportBounds, - ) - return sample ? [sample] : [] - }) -} - -function clipClientSample( - sample: ConformanceGeometrySample, - viewport: DOMRect, -): ConformanceGeometrySample | null { - const left = Math.max(sample.x, viewport.left) - const top = Math.max(sample.y, viewport.top) - const right = Math.min(sample.x + sample.width, viewport.right) - const bottom = Math.min(sample.y + sample.height, viewport.bottom) - if (right <= left || bottom <= top) return null - return { - x: left, - y: top, - width: right - left, - height: bottom - top, - paint: sample.paint, - } -} - function formatTaskDate(date: Date) { return date.toLocaleDateString(undefined, { month: 'short', diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/view.tsx b/benchmarks/conformance/cases/85-scrollable-resource-lanes/view.tsx new file mode 100644 index 00000000..9445d061 --- /dev/null +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/view.tsx @@ -0,0 +1,466 @@ +import { + forwardRef, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { Chart } from '@tanstack/charts/react' +import { reactMount } from '../../shared/react-mount' +import { timelineStatusColors } from './colors' +import { + timelineBodyHeight, + timelineChartHeight, + timelineContentWidth, + timelineLaneRailWidth, +} from './layout' +import { + resourceLanes, + resourceTasks, + resourceTimelineDomain, + timelineStatuses, +} from './scenario' +import { timelineDateKey } from './model' +import { resourceTimelineDefinition } from './tanstack' +import type { ChartPoint, ChartScene } from '@tanstack/charts' +import type { ResourceLane, ResourceTask } from './scenario' +import type { + ConformanceGeometryQuery, + ConformanceGeometrySample, + ConformanceTarget, + ConformanceTestDriver, +} from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const taskInset = 5 +const focusScrollPadding = 32 + +interface TimelineFocusState { + taskId: string | null + centerX: number | null + scrolled: boolean +} + +const ResourceTimelineExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function ResourceTimelineExample({ input, idPrefix }, ref) { + const viewportRef = useRef(null) + const chartSurfaceRef = useRef(null) + const sceneRef = useRef>(null) + const inputRef = useRef(input) + const focusRef = useRef({ + taskId: null, + centerX: null, + scrolled: false, + }) + const [focusedTask, setFocusedTask] = useState(null) + const [lanePositions, setLanePositions] = useState< + Readonly> + >({} as Record) + inputRef.current = input + const rows = useMemo(() => resourceTasks(input.revision), [input.revision]) + const definition = useMemo(() => resourceTimelineDefinition(input), [input]) + const railWidth = timelineLaneRailWidth(input.width) + const bodyHeight = timelineBodyHeight(input.height) + const viewportWidth = Math.max(1, input.width - railWidth) + const contentWidth = timelineContentWidth(viewportWidth) + const chartHeight = timelineChartHeight(bodyHeight) + + useLayoutEffect(() => { + const viewport = viewportRef.current + if (!viewport) return + viewport.scrollLeft = Math.min( + viewport.scrollLeft, + Math.max(0, viewport.scrollWidth - viewport.clientWidth), + ) + }, [contentWidth, input.revision, viewportWidth]) + + const updateFocusedTask = (point: ChartPoint | null) => { + const viewport = viewportRef.current + const chartSurface = chartSurfaceRef.current + const keyboardFocused = + chartSurface?.querySelector('svg.ts-chart') === + chartSurface?.ownerDocument.activeElement + focusRef.current = { + taskId: point?.datum.id ?? null, + centerX: point?.x ?? null, + scrolled: + viewport && point && keyboardFocused + ? ensureTimelineFocusVisible(viewport, point.x) + : false, + } + setFocusedTask(point?.datum ?? null) + } + + useImperativeHandle( + ref, + () => ({ + resolveTarget(target) { + const viewport = viewportRef.current + const chartSurface = chartSurfaceRef.current + const scene = sceneRef.current + return viewport && chartSurface && scene + ? timelineTarget(viewport, chartSurface, scene, target) + : null + }, + readState() { + const viewport = viewportRef.current + return viewport + ? timelineState(viewport, inputRef.current, focusRef.current) + : {} + }, + geometry(query) { + const viewport = viewportRef.current + const chartSurface = chartSurfaceRef.current + const scene = sceneRef.current + return viewport && chartSurface && scene + ? timelineGeometry( + viewport, + chartSurface, + inputRef.current, + scene, + query, + ) + : [] + }, + }), + [], + ) + + return ( +
+
+ {timelineStatuses.map((status) => ( + + + {status[0]?.toUpperCase() + status.slice(1)} + + ))} + + {focusedTask ? taskDetails(focusedTask) : 'Scroll dates →'} + +
+
+
+ {resourceLanes.map((lane) => ( + + {lane} + + ))} +
+
+
+ { + sceneRef.current = scene + const next = Object.fromEntries( + resourceLanes.map((lane) => [lane, scene.scales.y.map(lane)]), + ) as Record + setLanePositions((current) => + resourceLanes.every((lane) => current[lane] === next[lane]) + ? current + : next, + ) + }} + /> +
+
+
+
    + {rows.map((row) => ( +
  • + {row.resource}: {row.label}, {row.status}, {formatDate(row.start)}{' '} + through {formatDate(row.end)} +
  • + ))} +
+
+ ) +}) + +export const mount = reactMount(ResourceTimelineExample) + +const visuallyHidden = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +} as const + +function ensureTimelineFocusVisible(viewport: HTMLDivElement, centerX: number) { + const previous = viewport.scrollLeft + const visibleStart = previous + focusScrollPadding + const visibleEnd = previous + viewport.clientWidth - focusScrollPadding + let next = previous + if (centerX < visibleStart) next = centerX - focusScrollPadding + else if (centerX > visibleEnd) { + next = centerX - viewport.clientWidth + focusScrollPadding + } + viewport.scrollLeft = Math.max( + 0, + Math.min(next, viewport.scrollWidth - viewport.clientWidth), + ) + return Math.abs(viewport.scrollLeft - previous) > 1 +} + +function timelineTarget( + viewport: HTMLDivElement, + chartSurface: HTMLDivElement, + scene: ChartScene, + target: ConformanceTarget, +) { + if (target.view !== undefined && target.view !== 'main') return null + if (target.anchor.startsWith('task:')) { + const taskId = target.anchor.slice('task:'.length) + const point = scene.points.find( + (candidate) => candidate.datum.id === taskId, + ) + const svg = chartSurface.querySelector('svg.ts-chart') + if (!point || !svg) return null + const bounds = svg.getBoundingClientRect() + return { + x: bounds.left + (point.x / scene.width) * bounds.width, + y: bounds.top + (point.y / scene.height) * bounds.height, + focusElement: svg, + } + } + if (target.anchor !== 'viewport') return null + const bounds = viewport.getBoundingClientRect() + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + focusElement: viewport, + } +} + +function timelineState( + viewport: HTMLDivElement, + input: ReactConformanceProps['input'], + focusState: TimelineFocusState, +) { + const rows = resourceTasks(input.revision) + const apiBuild = rows.find((row) => row.id === 'api-build') + const qualityRelease = rows.find((row) => row.id === 'quality-release') + return { + viewport: { + scrollLeft: viewport.scrollLeft, + clientWidth: viewport.clientWidth, + scrollWidth: viewport.scrollWidth, + }, + lanes: { count: resourceLanes.length, names: resourceLanes }, + tasks: { + count: rows.length, + ids: rows.map((row) => row.id), + apiBuildEnd: apiBuild ? timelineDateKey(apiBuild.end) : null, + qualityReleaseStart: qualityRelease + ? timelineDateKey(qualityRelease.start) + : null, + }, + domain: { + start: timelineDateKey(resourceTimelineDomain[0]), + end: timelineDateKey(resourceTimelineDomain[1]), + }, + focus: { + taskId: focusState.taskId, + visible: + focusState.centerX !== null && + focusState.centerX >= viewport.scrollLeft && + focusState.centerX <= viewport.scrollLeft + viewport.clientWidth, + scrolled: focusState.scrolled, + }, + } +} + +function timelineGeometry( + viewport: HTMLDivElement, + chartSurface: HTMLDivElement, + input: ReactConformanceProps['input'], + scene: ChartScene, + query: ConformanceGeometryQuery, +): readonly ConformanceGeometrySample[] { + if ( + (query.view !== undefined && query.view !== 'main') || + query.role !== 'rect' + ) { + return [] + } + const svg = chartSurface.querySelector('svg.ts-chart') + if (!svg) return [] + const svgBounds = svg.getBoundingClientRect() + const viewportBounds = viewport.getBoundingClientRect() + const scaleX = svgBounds.width / scene.width + const scaleY = svgBounds.height / scene.height + const height = Math.max( + 0, + (scene.scales.y.bandwidth - taskInset * 2) * scaleY, + ) + return resourceTasks(input.revision).flatMap((row) => { + const x1 = scene.scales.x.map(row.start) + const x2 = scene.scales.x.map(row.end) + const centerY = scene.scales.y.map(row.resource) + const sample = clipClientSample( + { + x: svgBounds.left + Math.min(x1, x2) * scaleX, + y: + svgBounds.top + + (centerY - scene.scales.y.bandwidth / 2 + taskInset) * scaleY, + width: Math.abs(x2 - x1) * scaleX, + height, + paint: timelineStatusColors[row.status], + }, + viewportBounds, + ) + return sample ? [sample] : [] + }) +} + +function clipClientSample( + sample: ConformanceGeometrySample, + viewport: DOMRect, +): ConformanceGeometrySample | null { + const left = Math.max(sample.x, viewport.left) + const top = Math.max(sample.y, viewport.top) + const right = Math.min(sample.x + sample.width, viewport.right) + const bottom = Math.min(sample.y + sample.height, viewport.bottom) + if (right <= left || bottom <= top) return null + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + paint: sample.paint, + } +} + +function taskDetails(task: ResourceTask) { + return `${task.resource} · ${task.label} · ${task.status} · ${formatDate(task.start)}–${formatDate(task.end)}` +} + +function formatDate(date: Date) { + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) +} diff --git a/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.test.ts b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.test.ts index ec4d0846..324fcb77 100644 --- a/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.test.ts +++ b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.test.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { travelers } from '@charts-poc/demo-data/travelers' import { createChartScene, resolveFocusScene } from '@tanstack/charts' +import { act } from 'react' import { describe, expect, expectTypeOf, it } from 'vitest' import { selectSynchronizedCursorData } from './selection' import { catalogCase, mount, synchronizedCursorDefinition } from './tanstack' @@ -112,16 +113,21 @@ describe('definition-owned synchronized cursors', () => { it('uses the native keyboard, pin, leave, update, and Escape lifecycle', () => { const container = document.createElement('div') document.body.append(container) - const handle = mount(container, input) + let handle!: ReturnType + act(() => { + handle = mount(container, input) + }) const svg = container.querySelector('svg.ts-chart') if (!svg || !handle.driver) throw new Error('Expected a mounted chart') - svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) - for (let index = 0; index < 3; index += 1) { - svg.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), - ) - } + act(() => { + svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + for (let index = 0; index < 3; index += 1) { + svg.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ) + } + }) expect(handle.driver.readState()).toMatchObject({ shared: { date: '2020-12-13', pinned: false }, crosshairs: { @@ -133,9 +139,19 @@ describe('definition-owned synchronized cursors', () => { container.querySelectorAll('.ts-chart__focus-guide-x-rule'), ).toHaveLength(2) - svg.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), - ) + act(() => { + handle.update({ ...input, revision: 1 }) + handle.update({ ...input, revision: 0 }) + }) + expect(handle.driver.readState()).toMatchObject({ + shared: { date: '2020-12-13', pinned: false }, + }) + + act(() => { + svg.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ) + }) expect(handle.driver.readState()).toMatchObject({ shared: { date: '2020-12-13', pinned: true }, }) @@ -147,19 +163,25 @@ describe('definition-owned synchronized cursors', () => { container.querySelector('.ts-chart-tooltip')?.hidden, ).toBe(false) - container.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })) + act(() => { + container.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })) + }) expect(handle.driver.readState()).toMatchObject({ shared: { date: '2020-12-13', pinned: true }, }) - handle.update({ ...input, revision: 1 }) + act(() => { + handle.update({ ...input, revision: 1 }) + }) expect(handle.driver.readState()).toMatchObject({ shared: { date: '2020-12-13', pinned: true }, }) - svg.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), - ) + act(() => { + svg.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ) + }) expect(handle.driver.readState()).toMatchObject({ shared: { date: null, pinned: false }, crosshairs: { @@ -172,7 +194,9 @@ describe('definition-owned synchronized cursors', () => { ?.textContent, ).toBe('Focus either chart') - handle.destroy() + act(() => { + handle.destroy() + }) container.remove() }) @@ -206,11 +230,9 @@ describe('definition-owned synchronized cursors', () => { 'benchmarks/conformance/cases/87-echarts-synchronized-cursors', ) const source = readFileSync(resolve(directory, 'tanstack.ts'), 'utf8') + const view = readFileSync(resolve(directory, 'view.tsx'), 'utf8') - expect(existsSync(resolve(directory, 'view.tsx'))).toBe(false) for (const forbidden of [ - "from 'react'", - '@tanstack/charts/react', 'createElementNS', 'data-conformance-overlay', 'scene.scales.x.map', @@ -224,6 +246,8 @@ describe('definition-owned synchronized cursors', () => { expect(source).toContain('focusGuideX(rows') expect(source).toContain("focus: 'group-x'") expect(source).toContain("visibility: 'pinned'") + expect(view).toContain("from '@tanstack/charts/react'") + expect(view).toContain(' synchronizedCursorDateKey(row.date), x: 'date', y: view, z: group, @@ -116,6 +114,7 @@ function synchronizedCursorViewDefinition( ), dot(rows, { id: `${view}-points`, + key: (row) => synchronizedCursorDateKey(row.date), x: 'date', y: view, z: group, @@ -126,6 +125,7 @@ function synchronizedCursorViewDefinition( }), focusGuideX(rows, { id: `${view}-guide`, + key: (row) => synchronizedCursorDateKey(row.date), x: 'date', y: view, z: group, @@ -180,68 +180,9 @@ export const catalogCase = tanstackCase( }, ) -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let focusedDate: Date | null = null - let pinned = false - const shell = container.ownerDocument.createElement('div') - const summary = createSynchronizedSummary(container.ownerDocument) - const chartFrame = container.ownerDocument.createElement('div') - shell.style.display = 'grid' - shell.style.gridTemplateRows = `${summaryHeight}px minmax(0, 1fr)` - chartFrame.style.minHeight = '0' - shell.append(summary.root, chartFrame) - container.append(shell) - sizeShell(shell, chartFrame, input) +export { mount } from './view' - const updateSummary = () => - updateSynchronizedSummary(summary, focusedDate, currentInput, pinned) - const options = (): ChartHostOptions => ({ - definition: synchronizedCursorDefinition(currentInput), - width: currentInput.width, - height: chartHeight(currentInput), - ariaLabel: 'Linked 2020 and 2019 airport traveler time series', - ariaDescription: - 'Move across either view or use the arrow keys to compare both years at the same date. Select a point to pin the cursor.', - onFocusGroupChange(points) { - const date = points[0]?.datum.date ?? null - focusedDate = date - if (!date) pinned = false - updateSummary() - }, - onSelect(point) { - if (!point) return - focusedDate = point.datum.date - pinned = !pinned - updateSummary() - }, - }) - const host = mountChart(chartFrame, options()) - updateSummary() - - const driver = createDriver( - chartFrame, - () => currentInput, - () => host.getScene(), - () => ({ date: focusedDate, pinned }), - ) - - return { - driver, - update(nextInput) { - currentInput = nextInput - sizeShell(shell, chartFrame, nextInput) - host.update(options()) - updateSummary() - }, - destroy() { - host.destroy() - shell.remove() - }, - } -} - -function createDriver( +export function createDriver( surface: HTMLElement, getInput: () => ConformanceInput, getScene: () => ChartScene, @@ -446,17 +387,6 @@ function renderedCrosshairState( } } -function chartHeight(input: ConformanceInput) { +export function chartHeight(input: ConformanceInput) { return Math.max(280, input.height - summaryHeight) } - -function sizeShell( - shell: HTMLElement, - chartFrame: HTMLElement, - input: ConformanceInput, -) { - shell.style.width = `${input.width}px` - shell.style.height = `${input.height}px` - chartFrame.style.width = `${input.width}px` - chartFrame.style.height = `${chartHeight(input)}px` -} diff --git a/benchmarks/conformance/cases/87-echarts-synchronized-cursors/view.tsx b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/view.tsx new file mode 100644 index 00000000..5b6fee84 --- /dev/null +++ b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/view.tsx @@ -0,0 +1,254 @@ +import { + forwardRef, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { travelers } from '@charts-poc/demo-data/travelers' +import { Chart } from '@tanstack/charts/react' +import { reactMount } from '../../shared/react-mount' +import { synchronizedCursorColors } from './colors' +import { + synchronizedCursorDateKey, + synchronizedCursorDatumAtDate, +} from './model' +import { selectSynchronizedCursorData } from './selection' +import { + chartHeight, + createDriver, + summaryHeight, + synchronizedCursorDefinition, +} from './tanstack' +import type { ChartScene } from '@tanstack/charts' +import type { ChartInteractionController } from '@tanstack/charts' +import type { TravelersRow } from '@charts-poc/demo-data/travelers' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const SynchronizedCursorsExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function SynchronizedCursorsExample({ input, idPrefix }, ref) { + const chartFrameRef = useRef(null) + const sceneRef = useRef>(null) + const interactionRef = useRef< + ChartInteractionController | undefined + >(undefined) + const inputRef = useRef(input) + const previousInputRef = useRef(input) + const restoreDateRef = useRef(undefined) + const stateRef = useRef<{ date: Date | null; pinned: boolean }>({ + date: null, + pinned: false, + }) + const [focusedDate, setFocusedDate] = useState(null) + const [pinned, setPinned] = useState(false) + if (previousInputRef.current !== input) { + restoreDateRef.current = stateRef.current.date + previousInputRef.current = input + } + inputRef.current = input + stateRef.current = { date: focusedDate, pinned } + const definition = useMemo(() => synchronizedCursorDefinition(input), [input]) + const rows = useMemo( + () => selectSynchronizedCursorData(travelers, input.revision), + [input.revision], + ) + const row = focusedDate + ? synchronizedCursorDatumAtDate(rows, focusedDate) + : undefined + + useImperativeHandle(ref, () => { + const surface = chartFrameRef.current + if (!surface) throw new Error('Missing synchronized cursor surface') + return createDriver( + surface, + () => inputRef.current, + () => { + if (!sceneRef.current) throw new Error('Missing chart scene') + return sceneRef.current + }, + () => stateRef.current, + ) + }, []) + + return ( +
{ + if (stateRef.current.pinned) return + interactionRef.current?.setControlledFocus(null) + stateRef.current = { date: null, pinned: false } + setFocusedDate(null) + setPinned(false) + }} + style={{ + display: 'grid', + gridTemplateRows: `${summaryHeight}px minmax(0, 1fr)`, + width: input.width, + height: input.height, + }} + > +
+ + + +
+
+ { + const date = points[0]?.datum.date ?? null + setFocusedDate(date) + if (!date) setPinned(false) + }} + onSelect={(point) => { + if (!point) return + setFocusedDate(point.datum.date) + setPinned((value) => !value) + }} + onRender={({ interaction, scene }) => { + sceneRef.current = scene + interactionRef.current = interaction + const restoreDate = restoreDateRef.current + restoreDateRef.current = undefined + if (!restoreDate) return + const timestamp = restoreDate.getTime() + const point = scene.points.find( + (candidate) => + candidate.markId.endsWith(':current:current-points') && + candidate.datum.date.getTime() === timestamp, + ) + if (point) { + interaction.setControlledFocus(point, { + source: 'programmatic', + pinned: stateRef.current.pinned, + }) + } + }} + /> +
+
+ ) +}) + +export const mount = reactMount(SynchronizedCursorsExample) + +function SummaryValue({ + color, + dataAttribute, + label, + value, +}: { + color: string + dataAttribute: 'date' | 'current' | 'previous' + label: string + value: string +}) { + return ( + + ) +} + +function formatDate(date: Date) { + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) +} diff --git a/benchmarks/conformance/cases/88-echarts-free-cursor/controls.ts b/benchmarks/conformance/cases/88-echarts-free-cursor/controls.ts index 11fe0430..be3985bd 100644 --- a/benchmarks/conformance/cases/88-echarts-free-cursor/controls.ts +++ b/benchmarks/conformance/cases/88-echarts-free-cursor/controls.ts @@ -162,12 +162,7 @@ function sliderLabel( return label } -export function formatFreeCursorValue(axis: string, value: number) { - return `${axis} ${value.toLocaleString(undefined, { - maximumFractionDigits: 1, - })}` -} - function clamp(value: number, minimum: number, maximum: number) { return Math.max(minimum, Math.min(maximum, value)) } +import { formatFreeCursorValue } from './format' diff --git a/benchmarks/conformance/cases/88-echarts-free-cursor/format.ts b/benchmarks/conformance/cases/88-echarts-free-cursor/format.ts new file mode 100644 index 00000000..19f5a7f9 --- /dev/null +++ b/benchmarks/conformance/cases/88-echarts-free-cursor/format.ts @@ -0,0 +1,5 @@ +export function formatFreeCursorValue(axis: string, value: number) { + return `${axis} ${value.toLocaleString(undefined, { + maximumFractionDigits: 1, + })}` +} diff --git a/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.test.ts b/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.test.ts index 003816db..33ee6c39 100644 --- a/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.test.ts +++ b/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.test.ts @@ -1,6 +1,7 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { createChartScene } from '@tanstack/charts' +import { act } from 'react' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { catalogCase, freeCursorDefinition, mount } from './tanstack' import type { @@ -112,7 +113,10 @@ describe('definition-owned free cursor', () => { it('previews without rendering, pins on touch, survives leave, and clears on Escape', () => { const container = document.createElement('div') document.body.append(container) - const handle = mount(container, input) + let handle!: ReturnType + act(() => { + handle = mount(container, input) + }) const driver = handle.driver const surface = container.querySelector('svg.ts-chart') const overlay = container.querySelector( @@ -138,7 +142,9 @@ describe('definition-owned free cursor', () => { y: scene.chart.y + scene.chart.height * 0.64, } - overlay.dispatchEvent(pointer('pointermove', preview.x, preview.y)) + act(() => { + overlay.dispatchEvent(pointer('pointermove', preview.x, preview.y)) + }) expect(driver.readState()).toMatchObject({ cursor: { @@ -156,23 +162,32 @@ describe('definition-owned free cursor', () => { ?.textContent, ).toContain('HP 101.8 · MPG 20.8') - overlay.dispatchEvent(pointer('pointerleave', preview.x, preview.y)) + act(() => { + overlay.dispatchEvent(pointer('pointerleave', preview.x, preview.y)) + }) expect(driver.readState()).toMatchObject({ cursor: { visible: false, pinned: false }, }) + const sliders = container.querySelectorAll( + 'input[type="range"]', + ) + expect(sliders[0]?.value).toBe('101.8') + expect(sliders[1]?.value).toBe('20.8') const touch = { x: scene.chart.x + scene.chart.width * 0.25, y: scene.chart.y + scene.chart.height * 0.25, } - overlay.dispatchEvent(pointer('pointerdown', touch.x, touch.y, 'touch')) - overlay.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - clientX: touch.x, - clientY: touch.y, - }), - ) + act(() => { + overlay.dispatchEvent(pointer('pointerdown', touch.x, touch.y, 'touch')) + overlay.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + clientX: touch.x, + clientY: touch.y, + }), + ) + }) expect(driver.readState()).toMatchObject({ cursor: { visible: true, @@ -182,19 +197,25 @@ describe('definition-owned free cursor', () => { }, }) - overlay.dispatchEvent(pointer('pointerleave', touch.x, touch.y, 'touch')) + act(() => { + overlay.dispatchEvent(pointer('pointerleave', touch.x, touch.y, 'touch')) + }) expect(driver.readState()).toMatchObject({ cursor: { visible: true, pinned: true }, }) - overlay.dispatchEvent( - new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }), - ) + act(() => { + overlay.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }), + ) + }) expect(driver.readState()).toMatchObject({ cursor: { visible: false, pinned: false }, }) - handle.destroy() + act(() => { + handle.destroy() + }) container.remove() }) @@ -205,10 +226,8 @@ describe('definition-owned free cursor', () => { ) const source = readFileSync(resolve(directory, 'tanstack.ts'), 'utf8') - expect(existsSync(resolve(directory, 'view.tsx'))).toBe(false) + const view = readFileSync(resolve(directory, 'view.tsx'), 'utf8') for (const forbidden of [ - "from 'react'", - '@tanstack/charts/react', 'createElementNS', 'data-conformance-overlay', '.copy()', @@ -223,6 +242,8 @@ describe('definition-owned free cursor', () => { expect(source).toContain('controlledSignal<') expect(source).toContain('(next, { reason }) => onChange(next, reason)') expect(source).toContain('decorative(') + expect(view).toContain("from '@tanstack/charts/react'") + expect(view).toContain(' { - let currentInput = input - let accepted: ContinuousCursorPosition | null = null - let state = clearedCursor() - let renderCount = 0 - let host: ChartHost | undefined - - const shell = container.ownerDocument.createElement('div') - const chartFrame = container.ownerDocument.createElement('div') - const controls = createFreeCursorControls( - container.ownerDocument, - (x, y) => accept({ x, y }), - { - xDomain: freeCursorXDomain, - yDomain: freeCursorYDomain, - xLabel: 'Horsepower', - yLabel: 'Fuel economy', - xStep: 0.1, - yStep: 0.1, - }, - ) - shell.style.display = 'grid' - shell.style.gridTemplateRows = `${cursorControlsHeight}px minmax(0, 1fr)` - chartFrame.style.minHeight = '0' - chartFrame.dataset.conformanceView = 'main' - shell.append(controls.root, chartFrame) - container.append(shell) - sizeShell(shell, chartFrame, input) +export { mount } from './view' - const handleCursorChange = ( - value: ContinuousCursorPosition | null, - reason: ContinuousCursorChange, - ) => { - if (reason.type === 'preview') { - state = value ? cursorState(value, false) : clearedCursor() - updateControls(controls, state) - return - } - accepted = value ? roundedPosition(value) : null - state = accepted ? cursorState(accepted, true) : clearedCursor() - updateControls(controls, state) - host?.update(options()) - } - - const options = (): ChartHostOptions => ({ - definition: freeCursorDefinition(accepted, handleCursorChange), - width: currentInput.width, - height: chartHeight(currentInput.height), - ariaLabel: 'Line chart with a free two-dimensional cursor', - ariaDescription: - 'Move across the plot to inspect arbitrary horsepower and fuel-economy coordinates. Select to pin the cursor; press Escape to clear it.', - onRender() { - renderCount += 1 - }, - }) - - function accept(next: ContinuousCursorPosition | null) { - accepted = next ? roundedPosition(next) : null - state = accepted ? cursorState(accepted, true) : clearedCursor() - updateControls(controls, state) - host?.update(options()) - } - - const handleControlKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || !state.visible) return - event.preventDefault() - accept(null) - } - controls.root.addEventListener('keydown', handleControlKeyDown) - - host = mountChart(chartFrame, options()) - updateControls(controls, state) - - const driver = createDriver( - chartFrame, - controls, - () => host!.getScene(), - () => state, - () => renderCount, - ) - - return { - driver, - update(nextInput) { - currentInput = nextInput - sizeShell(shell, chartFrame, nextInput) - host!.update(options()) - }, - destroy() { - controls.root.removeEventListener('keydown', handleControlKeyDown) - host!.destroy() - shell.remove() - }, - } +export interface FreeCursorControlRefs { + x: HTMLInputElement + y: HTMLInputElement } -function createDriver( +export function createDriver( surface: HTMLElement, - controls: FreeCursorControls, + controls: FreeCursorControlRefs, getScene: () => ChartScene, getState: () => CursorState, getRenderCount: () => number, @@ -295,7 +200,7 @@ function createDriver( function resolveTarget( surface: HTMLElement, - controls: FreeCursorControls, + controls: FreeCursorControlRefs, scene: ChartScene, target: ConformanceTarget, ) { @@ -366,7 +271,7 @@ function geometry( return sample ? [sample] : [] } -function cursorState( +export function cursorState( position: ContinuousCursorPosition, pinned: boolean, ): CursorState { @@ -387,7 +292,7 @@ function cursorState( } } -function clearedCursor(): CursorState { +export function clearedCursor(): CursorState { return { visible: false, xNormalized: null, @@ -417,16 +322,7 @@ function interactionState( } } -function updateControls(controls: FreeCursorControls, state: CursorState) { - updateFreeCursorControls(controls, { - visible: state.visible, - x: state.xValue, - y: state.yValue, - pinned: state.pinned, - }) -} - -function roundedPosition( +export function roundedPosition( position: ContinuousCursorPosition, ): ContinuousCursorPosition { return { @@ -439,17 +335,6 @@ function roundCursorValue(value: number) { return Math.round(value * 10) / 10 } -function sizeShell( - shell: HTMLDivElement, - chartFrame: HTMLDivElement, - input: ConformanceInput, -) { - shell.style.width = `${input.width}px` - shell.style.height = `${input.height}px` - chartFrame.style.width = `${input.width}px` - chartFrame.style.height = `${chartHeight(input.height)}px` -} - -function chartHeight(height: number) { +export function chartHeight(height: number) { return Math.max(180, height - cursorControlsHeight) } diff --git a/benchmarks/conformance/cases/88-echarts-free-cursor/view.tsx b/benchmarks/conformance/cases/88-echarts-free-cursor/view.tsx new file mode 100644 index 00000000..120902e6 --- /dev/null +++ b/benchmarks/conformance/cases/88-echarts-free-cursor/view.tsx @@ -0,0 +1,277 @@ +import { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react' +import { Chart } from '@tanstack/charts/react' +import { reactMount } from '../../shared/react-mount' +import { formatFreeCursorValue } from './format' +import { freeCursorXDomain, freeCursorYDomain } from './model' +import { + chartHeight, + clearedCursor, + createDriver, + cursorControlsHeight, + cursorState, + freeCursorDefinition, + roundedPosition, +} from './tanstack' +import type { ChartScene } from '@tanstack/charts' +import type { + ContinuousCursorChange, + ContinuousCursorPosition, +} from '@tanstack/charts/interaction/cursor' +import type { CompleteCar } from './model' +import type { CursorState } from './tanstack' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const FreeCursorExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function FreeCursorExample({ input, idPrefix }, ref) { + const chartFrameRef = useRef(null) + const xRef = useRef(null) + const yRef = useRef(null) + const sceneRef = useRef>(null) + const stateRef = useRef(clearedCursor()) + const lastPositionRef = useRef>({ + x: (freeCursorXDomain[0] + freeCursorXDomain[1]) / 2, + y: (freeCursorYDomain[0] + freeCursorYDomain[1]) / 2, + }) + const renderCountRef = useRef(0) + const [accepted, setAccepted] = useState | null>(null) + const [state, setState] = useState(clearedCursor) + stateRef.current = state + + const accept = useCallback( + (value: ContinuousCursorPosition | null) => { + const next = value ? roundedPosition(value) : null + if (next) lastPositionRef.current = next + const nextState = next ? cursorState(next, true) : clearedCursor() + stateRef.current = nextState + setAccepted(next) + setState(nextState) + }, + [], + ) + const handleCursorChange = useCallback( + ( + value: ContinuousCursorPosition | null, + reason: ContinuousCursorChange, + ) => { + if (reason.type === 'preview') { + if (value) lastPositionRef.current = roundedPosition(value) + const nextState = value ? cursorState(value, false) : clearedCursor() + stateRef.current = nextState + setState(nextState) + return + } + accept(value) + }, + [accept], + ) + + useImperativeHandle(ref, () => { + const surface = chartFrameRef.current + const x = xRef.current + const y = yRef.current + if (!surface || !x || !y) throw new Error('Missing free cursor view') + return createDriver( + surface, + { x, y }, + () => { + if (!sceneRef.current) throw new Error('Missing free cursor scene') + return sceneRef.current + }, + () => stateRef.current, + () => renderCountRef.current, + ) + }, []) + + const visible = + state.visible && state.xValue !== null && state.yValue !== null + const xValue = visible ? state.xValue! : lastPositionRef.current.x + const yValue = visible ? state.yValue! : lastPositionRef.current.y + const status = visible + ? `${formatFreeCursorValue('HP', xValue)} · ${formatFreeCursorValue( + 'MPG', + yValue, + )}${state.pinned ? ' · pinned' : ''}` + : 'Move the pointer or adjust horsepower and fuel economy' + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || !stateRef.current.visible) return + event.preventDefault() + accept(null) + } + + return ( +
+
+ accept({ x, y: yValue })} + /> + accept({ x: xValue, y })} + /> + + {status} + +
+
+ { + sceneRef.current = scene + renderCountRef.current += 1 + }} + /> +
+
+ ) +}) + +export const mount = reactMount(FreeCursorExample) + +const FreeCursorChart = memo(function FreeCursorChart({ + accepted, + idPrefix, + input, + onChange, + onRender, +}: { + accepted: ContinuousCursorPosition | null + idPrefix?: string + input: ReactConformanceProps['input'] + onChange: Parameters[1] + onRender: (scene: ChartScene) => void +}) { + const definition = useMemo( + () => freeCursorDefinition(accepted, onChange), + [accepted, onChange], + ) + return ( + onRender(scene)} + /> + ) +}) + +const CursorSlider = forwardRef< + HTMLInputElement, + { + ariaLabel: string + domain: readonly [number, number] + label: string + onChange: (value: number) => void + value: number + } +>(function CursorSlider({ ariaLabel, domain, label, onChange, value }, ref) { + return ( + + ) +}) diff --git a/benchmarks/conformance/cases/89-brush-range-selection/tanstack.test.ts b/benchmarks/conformance/cases/89-brush-range-selection/tanstack.test.ts index d06da240..a9be5834 100644 --- a/benchmarks/conformance/cases/89-brush-range-selection/tanstack.test.ts +++ b/benchmarks/conformance/cases/89-brush-range-selection/tanstack.test.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { aapl } from '@charts-poc/demo-data/aapl' import { createChartScene } from '@tanstack/charts' +import { act } from 'react' import { describe, expect, expectTypeOf, it } from 'vitest' import { initialBrushRange, monthlyAaplRows, observedBrushDates } from './model' import { brushRangeDefinition, mount } from './tanstack' @@ -54,7 +55,10 @@ describe('definition-owned brush range', () => { it('accepts semantic handle changes and preserves the range through updates', () => { const container = document.createElement('div') document.body.append(container) - const handle = mount(container, input) + let handle!: ReturnType + act(() => { + handle = mount(container, input) + }) const driver = handle.driver const end = container.querySelector( '[data-chart-brush-handle="end"]', @@ -76,9 +80,11 @@ describe('definition-owned brush range', () => { ).toBe('application') end.focus() - end.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), - ) + act(() => { + end.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ) + }) expect(driver.readState()).toMatchObject({ selection: { start: '2017-04-28', @@ -92,15 +98,19 @@ describe('definition-owned brush range', () => { 'avg $147.3', ) - handle.update({ ...input, revision: 1 }) + act(() => { + handle.update({ ...input, revision: 1 }) + }) expect(driver.readState()).toMatchObject({ selection: { start: '2017-04-28', end: '2017-07-31' }, }) expect(document.activeElement).toBe(end) - end.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Home', bubbles: true }), - ) + act(() => { + end.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Home', bubbles: true }), + ) + }) expect(driver.readState()).toMatchObject({ selection: { start: '2017-04-28', @@ -109,7 +119,9 @@ describe('definition-owned brush range', () => { }, }) - handle.destroy() + act(() => { + handle.destroy() + }) expect(container.childElementCount).toBe(0) container.remove() }) @@ -121,10 +133,8 @@ describe('definition-owned brush range', () => { ) const source = readFileSync(resolve(directory, 'tanstack.ts'), 'utf8') - expect(existsSync(resolve(directory, 'view.tsx'))).toBe(false) + const view = readFileSync(resolve(directory, 'view.tsx'), 'utf8') for (const forbidden of [ - "from 'react'", - '@tanstack/charts/react', "from 'd3-brush'", "from 'd3-selection'", 'createElementNS', @@ -144,5 +154,7 @@ describe('definition-owned brush range', () => { expect(source).toContain('controlledSignal<') expect(source).toContain('(next, { reason }) => onChange(next, reason)') expect(source).toContain('decorative(') + expect(view).toContain("from '@tanstack/charts/react'") + expect(view).toContain(' dragging: boolean } @@ -127,74 +125,9 @@ export const catalogCase = tanstackCase( 'Time series with a draggable horizontal range brush', ) -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let accepted = copyRange(initialBrushRange(brushDates)) - let state: BrushState = { range: copyRange(accepted), dragging: false } - let host: ChartHost | undefined +export { mount } from './view' - const shell = container.ownerDocument.createElement('div') - const chartFrame = container.ownerDocument.createElement('div') - const status = createRangeStatus(container.ownerDocument) - shell.dataset.conformanceView = 'main' - shell.setAttribute('role', 'application') - shell.setAttribute( - 'aria-label', - 'Monthly time range brush with two adjustable handles', - ) - shell.style.position = 'relative' - chartFrame.style.position = 'relative' - shell.append(chartFrame, status) - container.append(shell) - sizeShell(shell, chartFrame, input) - - const handleBrushChange = ( - next: BrushRange, - reason: BrushXChange, - ) => { - state = { - range: copyRange(next), - dragging: reason.type === 'preview', - } - updateRangeStatus(status, state.range) - if (reason.type === 'preview') return - accepted = copyRange(next) - host?.update(options()) - } - - const options = (): ChartHostOptions => ({ - definition: brushRangeDefinition(accepted, handleBrushChange), - width: currentInput.width, - height: currentInput.height, - ariaLabel: 'Time series with a draggable horizontal range brush', - }) - - host = mountChart(chartFrame, options()) - updateRangeStatus(status, state.range) - - const driver = createDriver( - shell, - chartFrame, - () => host!.getScene(), - () => state, - ) - - return { - driver, - update(nextInput) { - currentInput = nextInput - sizeShell(shell, chartFrame, nextInput) - host!.update(options()) - updateRangeStatus(status, state.range) - }, - destroy() { - host!.destroy() - shell.remove() - }, - } -} - -function createDriver( +export function createDriver( shell: HTMLElement, surface: HTMLElement, getScene: () => ChartScene, @@ -327,38 +260,15 @@ function brushGeometry( ] } -function createRangeStatus(document: Document) { - const status = document.createElement('output') - status.setAttribute('role', 'status') - status.setAttribute('aria-live', 'polite') - Object.assign(status.style, { - position: 'absolute', - right: '24px', - top: '10px', - zIndex: '4', - padding: '4px 8px', - border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)', - borderRadius: '999px', - background: 'Canvas', - color: 'CanvasText', - font: '600 12px/1.2 system-ui, sans-serif', - pointerEvents: 'none', - }) - return status -} - -function updateRangeStatus(status: HTMLOutputElement, range: BrushRange) { +export function brushRangeStatus(range: BrushRange) { const summary = brushRangeSummary(brushRows, range) - const label = `${brushShortDate(range.start)} → ${brushShortDate(range.end)} · ${summary.count} AAPL closes · avg $${summary.average.toFixed(1)}` - status.value = label - status.textContent = label - status.setAttribute( - 'aria-label', - `${brushDateKey(range.start)} through ${brushDateKey(range.end)}, ${summary.count} AAPL closing prices, average $${summary.average.toFixed(1)}`, - ) + return { + label: `${brushShortDate(range.start)} → ${brushShortDate(range.end)} · ${summary.count} AAPL closes · avg $${summary.average.toFixed(1)}`, + ariaLabel: `${brushDateKey(range.start)} through ${brushDateKey(range.end)}, ${summary.count} AAPL closing prices, average $${summary.average.toFixed(1)}`, + } } -function copyRange(range: BrushRange): BrushRange { +export function copyRange(range: BrushRange): BrushRange { return { start: new Date(range.start.getTime()), end: new Date(range.end.getTime()), @@ -373,14 +283,3 @@ function center(element: HTMLElement | SVGElement) { focusElement: element, } } - -function sizeShell( - shell: HTMLDivElement, - chartFrame: HTMLDivElement, - input: ConformanceInput, -) { - shell.style.width = `${input.width}px` - shell.style.height = `${input.height}px` - chartFrame.style.width = `${input.width}px` - chartFrame.style.height = `${input.height}px` -} diff --git a/benchmarks/conformance/cases/89-brush-range-selection/view.tsx b/benchmarks/conformance/cases/89-brush-range-selection/view.tsx new file mode 100644 index 00000000..7fd98814 --- /dev/null +++ b/benchmarks/conformance/cases/89-brush-range-selection/view.tsx @@ -0,0 +1,132 @@ +import { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { Chart } from '@tanstack/charts/react' +import { reactMount } from '../../shared/react-mount' +import { initialBrushRange, observedBrushDates, monthlyAaplRows } from './model' +import { aapl } from '@charts-poc/demo-data/aapl' +import { + brushRangeDefinition, + brushRangeStatus, + copyRange, + createDriver, +} from './tanstack' +import type { ChartScene } from '@tanstack/charts' +import type { + BrushRange, + BrushXChange, +} from '@tanstack/charts/interaction/brush' +import type { AaplRow } from '@charts-poc/demo-data/aapl' +import type { BrushState } from './tanstack' +import type { ConformanceTestDriver } from '../../types' +import type { ReactConformanceProps } from '../../shared/react-mount' + +const initialRange = initialBrushRange( + observedBrushDates(monthlyAaplRows(aapl)), +) + +const BrushRangeExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function BrushRangeExample({ input, idPrefix }, ref) { + const shellRef = useRef(null) + const chartRef = useRef(null) + const sceneRef = useRef>(null) + const [accepted, setAccepted] = useState(() => copyRange(initialRange)) + const [state, setState] = useState(() => ({ + range: copyRange(initialRange), + dragging: false, + })) + const stateRef = useRef(state) + stateRef.current = state + + const handleBrushChange = useCallback( + (next: BrushRange, reason: BrushXChange) => { + const nextState = { + range: copyRange(next), + dragging: reason.type === 'preview', + } + stateRef.current = nextState + setState(nextState) + if (reason.type !== 'preview') setAccepted(copyRange(next)) + }, + [], + ) + const definition = useMemo( + () => brushRangeDefinition(accepted, handleBrushChange), + [accepted, handleBrushChange], + ) + + useImperativeHandle(ref, () => { + const shell = shellRef.current + const chart = chartRef.current + if (!shell || !chart) throw new Error('Missing brush range view') + return createDriver( + shell, + chart, + () => { + if (!sceneRef.current) throw new Error('Missing brush range scene') + return sceneRef.current + }, + () => stateRef.current, + ) + }, []) + + const status = brushRangeStatus(state.range) + return ( +
+
+ { + sceneRef.current = scene + }} + /> +
+ + {status.label} + +
+ ) +}) + +export const mount = reactMount(BrushRangeExample) diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts index 5d341a86..fed62894 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts +++ b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { aapl } from '@charts-poc/demo-data/aapl' import { createChartScene } from '@tanstack/charts' +import { act } from 'react' import { describe, expect, expectTypeOf, it } from 'vitest' import { initialZoomWindow, @@ -47,7 +48,10 @@ describe('definition-owned zoomable time window', () => { it('accepts keyboard changes, external reset, and responsive updates', () => { const container = document.createElement('div') document.body.append(container) - const handle = mount(container, input) + let handle!: ReturnType + act(() => { + handle = mount(container, input) + }) const driver = handle.driver const surface = container.querySelector( '[data-chart-zoom-surface]', @@ -70,9 +74,11 @@ describe('definition-owned zoomable time window', () => { }) surface.focus() - surface.dispatchEvent( - new KeyboardEvent('keydown', { key: '+', bubbles: true }), - ) + act(() => { + surface.dispatchEvent( + new KeyboardEvent('keydown', { key: '+', bubbles: true }), + ) + }) expect(driver.readState()).toMatchObject({ viewport: { start: '2018-01-06', @@ -83,13 +89,17 @@ describe('definition-owned zoomable time window', () => { }) expect(document.activeElement).toBe(surface) - handle.update({ ...input, revision: 1 }) + act(() => { + handle.update({ ...input, revision: 1 }) + }) expect(driver.readState()).toMatchObject({ viewport: { start: '2018-01-06', end: '2018-01-14' }, }) expect(document.activeElement).toBe(surface) - reset.click() + act(() => { + reset.click() + }) expect(driver.readState()).toMatchObject({ viewport: { start: '2018-01-02', @@ -103,7 +113,9 @@ describe('definition-owned zoomable time window', () => { '2018-01-02 → 2018-01-18', ) - handle.destroy() + act(() => { + handle.destroy() + }) expect(container.childElementCount).toBe(0) container.remove() }) @@ -125,10 +137,8 @@ describe('definition-owned zoomable time window', () => { ) const source = readFileSync(resolve(directory, 'tanstack.ts'), 'utf8') - expect(existsSync(resolve(directory, 'view.tsx'))).toBe(false) + const view = readFileSync(resolve(directory, 'view.tsx'), 'utf8') for (const forbidden of [ - "from 'react'", - '@tanstack/charts/react', "from 'd3-zoom'", "from 'd3-selection'", 'createElementNS', @@ -151,5 +161,7 @@ describe('definition-owned zoomable time window', () => { expect(source).toContain('controlledSignal<') expect(source).toContain('(next, { reason }) => onChange(next, reason)') expect(source).toContain('decorative(') + expect(view).toContain("from '@tanstack/charts/react'") + expect(view).toContain('data-conformance-zoom-reset') }) }) diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts index c3617060..29dd3104 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts +++ b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts @@ -1,5 +1,5 @@ import { aapl } from '@charts-poc/demo-data/aapl' -import { defineChart, dot, lineY, mountChart } from '@tanstack/charts' +import { defineChart, dot, lineY } from '@tanstack/charts' import { zoomX } from '@tanstack/charts/interaction/zoom' import { controlledSignal } from '@tanstack/charts/interaction/signal' import { decorative } from '@tanstack/charts/mark/decorative' @@ -24,18 +24,16 @@ import type { ZoomXChange, ZoomXWindow, } from '@tanstack/charts/interaction/zoom' -import type { ChartHost, ChartHostOptions, ChartScene } from '@tanstack/charts' +import type { ChartScene } from '@tanstack/charts' import type { ConformanceGeometryQuery, ConformanceGeometrySample, - ConformanceInput, ConformanceJsonObject, - ConformanceMount, ConformanceTarget, ConformanceTestDriver, } from '../../types' -interface ZoomState { +export interface ZoomState { window: ZoomXWindow lastAction: 'none' | ZoomXAction active: boolean @@ -126,111 +124,9 @@ export const catalogCase = tanstackCase( 'Time series with a wheel-zoomable and pannable time viewport', ) -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let accepted = copyWindow(initialZoomWindow) - let state: ZoomState = { - window: copyWindow(accepted), - lastAction: 'none', - active: false, - wheelCaptured: false, - } - let host: ChartHost | undefined - - const document = container.ownerDocument - const shell = document.createElement('div') - const chartFrame = document.createElement('div') - const status = createZoomStatus(document) - const reset = createResetButton(document) - shell.dataset.conformanceView = 'main' - shell.style.position = 'relative' - chartFrame.style.position = 'relative' - shell.append(chartFrame, status, reset) - container.append(shell) - sizeShell(shell, chartFrame, input) - - const updateStatus = () => { - const label = state.active - ? `${zoomDateKey(state.window.start)} → ${zoomDateKey(state.window.end)} · ${formatSpan(zoomSpanDays(state.window))} days` - : 'Focus chart to zoom' - status.value = label - status.textContent = label - } - - const handleZoomChange = ( - next: ZoomXWindow, - reason: ZoomXChange, - ) => { - accepted = copyWindow(next) - state = { - ...state, - window: copyWindow(next), - lastAction: reason.action, - wheelCaptured: state.wheelCaptured || reason.source === 'wheel', - } - updateStatus() - host?.update(options()) - } - - const handleActiveChange = (active: boolean) => { - state = { ...state, active } - updateStatus() - } +export { mount } from './view' - const options = (): ChartHostOptions => ({ - definition: zoomTimeWindowDefinition( - accepted, - handleZoomChange, - handleActiveChange, - ), - width: currentInput.width, - height: currentInput.height, - ariaLabel: 'Time series with a wheel-zoomable and pannable time viewport', - }) - - host = mountChart(chartFrame, options()) - updateStatus() - - const applyReset = () => { - accepted = copyWindow(initialZoomWindow) - state = { - ...state, - window: copyWindow(accepted), - lastAction: 'reset', - } - host!.update(options()) - updateStatus() - chartFrame.querySelector('[data-chart-zoom-surface]')?.focus() - } - const preserveChartFocus = (event: PointerEvent) => event.preventDefault() - reset.addEventListener('pointerdown', preserveChartFocus) - reset.addEventListener('click', applyReset) - - const driver = createDriver( - shell, - chartFrame, - () => host!.getScene(), - () => state, - ) - - return { - driver, - update(nextInput) { - currentInput = nextInput - sizeShell(shell, chartFrame, nextInput) - host!.update(options()) - updateStatus() - }, - destroy() { - reset.removeEventListener('pointerdown', preserveChartFocus) - reset.removeEventListener('click', applyReset) - host!.destroy() - shell.remove() - }, - } -} - -function createDriver( +export function createDriver( shell: HTMLElement, surface: HTMLElement, getScene: () => ChartScene, @@ -349,52 +245,13 @@ function zoomGeometry( return sample ? [sample] : [] } -function createZoomStatus(document: Document) { - const status = document.createElement('output') - status.dataset.conformanceZoomStatus = 'true' - status.setAttribute('role', 'status') - status.setAttribute('aria-live', 'polite') - Object.assign(status.style, { - position: 'absolute', - top: '10px', - right: '76px', - zIndex: '4', - padding: '4px 8px', - border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)', - borderRadius: '999px', - background: 'Canvas', - color: 'CanvasText', - font: '600 12px/1.2 system-ui, sans-serif', - pointerEvents: 'none', - }) - return status -} - -function createResetButton(document: Document) { - const reset = document.createElement('button') - reset.type = 'button' - reset.dataset.conformanceZoomReset = 'true' - reset.textContent = '↺' - reset.title = 'Reset zoom' - reset.setAttribute('aria-label', 'Reset zoom') - Object.assign(reset.style, { - position: 'absolute', - top: '6px', - right: '20px', - zIndex: '4', - width: '44px', - height: '44px', - border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)', - borderRadius: '10px', - background: 'Canvas', - color: 'CanvasText', - cursor: 'pointer', - font: '700 20px/1 system-ui, sans-serif', - }) - return reset +export function zoomStatusLabel(state: ZoomState) { + return state.active + ? `${zoomDateKey(state.window.start)} → ${zoomDateKey(state.window.end)} · ${formatSpan(zoomSpanDays(state.window))} days` + : 'Focus chart to zoom' } -function copyWindow(window: ZoomXWindow): ZoomXWindow { +export function copyWindow(window: ZoomXWindow): ZoomXWindow { return { start: new Date(window.start.getTime()), end: new Date(window.end.getTime()), @@ -413,14 +270,3 @@ function center(element: HTMLElement | SVGElement) { focusElement: element, } } - -function sizeShell( - shell: HTMLDivElement, - chartFrame: HTMLDivElement, - input: ConformanceInput, -) { - shell.style.width = `${input.width}px` - shell.style.height = `${input.height}px` - chartFrame.style.width = `${input.width}px` - chartFrame.style.height = `${input.height}px` -} diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/view.tsx b/benchmarks/conformance/cases/90-zoomable-time-window/view.tsx new file mode 100644 index 00000000..5b4ad8a4 --- /dev/null +++ b/benchmarks/conformance/cases/90-zoomable-time-window/view.tsx @@ -0,0 +1,175 @@ +import { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { Chart } from '@tanstack/charts/react' +import { reactMount } from '../../shared/react-mount' +import { initialZoomWindow } from './model' +import { + copyWindow, + createDriver, + zoomStatusLabel, + zoomTimeWindowDefinition, +} from './tanstack' +import type { AaplRow } from '@charts-poc/demo-data/aapl' +import type { ChartScene } from '@tanstack/charts' +import type { + ZoomXChange, + ZoomXWindow, +} from '@tanstack/charts/interaction/zoom' +import type { ReactConformanceProps } from '../../shared/react-mount' +import type { ConformanceTestDriver } from '../../types' +import type { ZoomState } from './tanstack' + +const ZoomTimeWindowExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function ZoomTimeWindowExample({ input, idPrefix }, ref) { + const shellRef = useRef(null) + const chartRef = useRef(null) + const sceneRef = useRef>(null) + const [accepted, setAccepted] = useState(() => copyWindow(initialZoomWindow)) + const [state, setState] = useState(() => ({ + window: copyWindow(initialZoomWindow), + lastAction: 'none', + active: false, + wheelCaptured: false, + })) + const stateRef = useRef(state) + stateRef.current = state + + const handleZoomChange = useCallback( + (next: ZoomXWindow, reason: ZoomXChange) => { + const nextWindow = copyWindow(next) + const nextState: ZoomState = { + ...stateRef.current, + window: nextWindow, + lastAction: reason.action, + wheelCaptured: + stateRef.current.wheelCaptured || reason.source === 'wheel', + } + stateRef.current = nextState + setAccepted(nextWindow) + setState(nextState) + }, + [], + ) + const handleActiveChange = useCallback((active: boolean) => { + const nextState = { ...stateRef.current, active } + stateRef.current = nextState + setState(nextState) + }, []) + const definition = useMemo( + () => + zoomTimeWindowDefinition(accepted, handleZoomChange, handleActiveChange), + [accepted, handleActiveChange, handleZoomChange], + ) + + useImperativeHandle(ref, () => { + const shell = shellRef.current + const chart = chartRef.current + if (!shell || !chart) throw new Error('Missing zoom time window view') + return createDriver( + shell, + chart, + () => { + if (!sceneRef.current) throw new Error('Missing zoom time window scene') + return sceneRef.current + }, + () => stateRef.current, + ) + }, []) + + const reset = () => { + const nextWindow = copyWindow(initialZoomWindow) + const nextState: ZoomState = { + ...stateRef.current, + window: nextWindow, + lastAction: 'reset', + } + stateRef.current = nextState + setAccepted(nextWindow) + setState(nextState) + chartRef.current + ?.querySelector('[data-chart-zoom-surface]') + ?.focus() + } + + return ( +
+
+ { + sceneRef.current = scene + }} + /> +
+ + {zoomStatusLabel(state)} + + +
+ ) +}) + +export const mount = reactMount(ZoomTimeWindowExample) diff --git a/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.test.ts b/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.test.ts index e78c06dd..0a2df224 100644 --- a/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.test.ts +++ b/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.test.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { createChartScene } from '@tanstack/charts' +import { act } from 'react' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { initialPlaybackIndex, selectPlaybackRows } from './model' import { aapl } from '@charts-poc/demo-data/aapl' @@ -44,7 +45,10 @@ describe('definition-owned playback handle', () => { try { const container = document.createElement('div') document.body.append(container) - const handle = mount(container, input) + let handle!: ReturnType + act(() => { + handle = mount(container, input) + }) const driver = handle.driver const surface = container.querySelector( '[data-chart-handle-surface]', @@ -62,40 +66,52 @@ describe('definition-owned playback handle', () => { }) surface.focus() - surface.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'End', - bubbles: true, - cancelable: true, - }), - ) + act(() => { + surface.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + cancelable: true, + }), + ) + }) expect(driver.readState()).toMatchObject({ playhead: { index: 7, date: '2018-01-11' }, interaction: { scrubCount: 1, playing: false }, }) expect(document.activeElement).toBe(surface) - play.click() + act(() => { + play.click() + }) expect(driver.readState()).toMatchObject({ playhead: { index: 0, date: '2018-01-02' }, interaction: { playing: true }, }) - vi.advanceTimersByTime(700) + act(() => { + vi.advanceTimersByTime(700) + }) expect(driver.readState()).toMatchObject({ playhead: { index: 1, date: '2018-01-03' }, interaction: { playing: true }, }) - play.click() + act(() => { + play.click() + }) expect(driver.readState()).toMatchObject({ interaction: { playing: false }, }) - handle.update({ ...input, revision: 1 }) + act(() => { + handle.update({ ...input, revision: 1 }) + }) expect(driver.readState()).toMatchObject({ playhead: { index: 1, date: '2018-01-03' }, }) - handle.destroy() + act(() => { + handle.destroy() + }) expect(container.childElementCount).toBe(0) container.remove() } finally { @@ -109,6 +125,7 @@ describe('definition-owned playback handle', () => { 'benchmarks/conformance/cases/91-timeline-playback-scrubber', ) const source = readFileSync(resolve(directory, 'tanstack.ts'), 'utf8') + const view = readFileSync(resolve(directory, 'view.tsx'), 'utf8') expect(existsSync(resolve(directory, 'overlay.ts'))).toBe(true) for (const forbidden of [ @@ -130,6 +147,7 @@ describe('definition-owned playback handle', () => { expect(source).toContain('handleX({') expect(source).toContain('controlledSignal<') expect(source).toContain('(next, { reason }) => onChange(next, reason)') - expect(source).toContain('setInterval(') + expect(view).toContain("from '@tanstack/charts/react'") + expect(view).toContain('setInterval(') }) }) diff --git a/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts b/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts index 95c8b3bc..f4c860fe 100644 --- a/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts +++ b/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts @@ -1,4 +1,4 @@ -import { defineChart, dot, lineY, mountChart } from '@tanstack/charts' +import { defineChart, dot, lineY } from '@tanstack/charts' import { aapl } from '@charts-poc/demo-data/aapl' import { handleX } from '@tanstack/charts/interaction/handle' import { controlledSignal } from '@tanstack/charts/interaction/signal' @@ -17,18 +17,16 @@ import { import { tanstackCase } from '../../shared/mount' import type { AaplRow } from '@charts-poc/demo-data/aapl' import type { HandleXChange } from '@tanstack/charts/interaction/handle' -import type { ChartHost, ChartHostOptions, ChartScene } from '@tanstack/charts' +import type { ChartScene } from '@tanstack/charts' import type { ConformanceGeometryQuery, ConformanceGeometrySample, - ConformanceInput, ConformanceJsonObject, - ConformanceMount, ConformanceTarget, ConformanceTestDriver, } from '../../types' -interface PlaybackState { +export interface PlaybackState { frame: Date dragging: boolean scrubCount: number @@ -38,9 +36,9 @@ interface PlaybackState { const linePaint = '#2563eb' const playheadPaint = '#f97316' const margin = { top: 64, right: 24, bottom: 68, left: 56 } -const playbackRows = selectPlaybackRows(aapl) +export const playbackRows = selectPlaybackRows(aapl) const playbackDates = playbackRows.map((row) => row.Date) -const initialFrame = playbackRows[initialPlaybackIndex]?.Date +export const initialFrame = playbackRows[initialPlaybackIndex]?.Date if (!initialFrame) throw new Error('Playback requires an initial frame.') export function playbackDefinition( @@ -122,138 +120,9 @@ export const catalogCase = tanstackCase( 'AAPL closes with a draggable timeline playback scrubber', ) -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let accepted = cloneDate(initialFrame) - let state: PlaybackState = { - frame: cloneDate(accepted), - dragging: false, - scrubCount: 0, - playing: false, - } - let playbackTimer: ReturnType | undefined - let host: ChartHost | undefined - - const document = container.ownerDocument - const view = document.createElement('div') - const chartSurface = document.createElement('div') - const controls = createPlaybackControls(document) - view.dataset.conformanceView = 'main' - view.style.position = 'relative' - view.style.touchAction = 'pan-y' - view.append(chartSurface, controls.toolbar, controls.status) - container.append(view) - sizeView(view, input) - - const frameText = () => playbackValueText(rowForDate(state.frame)) - const paint = () => controls.paint(frameText(), state.playing) - const stopPlayback = (message?: string) => { - if (playbackTimer !== undefined) clearInterval(playbackTimer) - playbackTimer = undefined - state = { ...state, playing: false } - paint() - if (message) controls.announce(`${message}. ${frameText()}`) - } - const stopForScrub = () => { - if (state.playing) stopPlayback() - } +export { mount } from './view' - const handleFrameChange = (next: Date, reason: HandleXChange) => { - stopForScrub() - if (reason.type === 'preview') { - state = { ...state, frame: cloneDate(next), dragging: true } - paint() - return - } - if (reason.type === 'cancel') { - state = { - ...state, - frame: cloneDate(reason.origin), - dragging: false, - } - paint() - controls.announce(`Scrub canceled. ${frameText()}`) - return - } - accepted = cloneDate(next) - state = { - ...state, - frame: cloneDate(next), - dragging: false, - scrubCount: state.scrubCount + 1, - } - host?.update(options()) - paint() - controls.announce(`Frame selected. ${frameText()}`) - } - - const options = (): ChartHostOptions => ({ - definition: playbackDefinition(accepted, handleFrameChange), - width: currentInput.width, - height: currentInput.height, - ariaLabel: 'AAPL closes with a draggable timeline playback scrubber', - }) - - const applyFrame = (next: Date) => { - accepted = cloneDate(next) - state = { ...state, frame: cloneDate(next) } - host!.update(options()) - paint() - } - - const togglePlayback = () => { - if (state.playing) { - stopPlayback('Playback paused') - return - } - const lastIndex = playbackRows.length - 1 - const restarting = indexForDate(state.frame) >= lastIndex - if (restarting) applyFrame(playbackRows[0]!.Date) - state = { ...state, playing: true, dragging: false } - playbackTimer = setInterval(() => { - const index = indexForDate(state.frame) - if (index >= playbackRows.length - 1) { - stopPlayback('Playback ended') - return - } - applyFrame(playbackRows[index + 1]!.Date) - }, 700) - paint() - controls.announce( - `${restarting ? 'Playback restarted' : 'Playback started'}. ${frameText()}`, - ) - } - - controls.playButton.addEventListener('click', togglePlayback) - host = mountChart(chartSurface, options()) - paint() - - const driver = createDriver( - view, - chartSurface, - controls.playButton, - () => host!.getScene(), - () => state, - ) - - return { - driver, - update(nextInput) { - currentInput = nextInput - sizeView(view, nextInput) - host!.update(options()) - paint() - }, - destroy() { - if (playbackTimer !== undefined) clearInterval(playbackTimer) - controls.playButton.removeEventListener('click', togglePlayback) - host!.destroy() - view.remove() - }, - } -} - -function createDriver( +export function createDriver( view: HTMLElement, surface: HTMLElement, playButton: HTMLButtonElement, @@ -373,95 +242,6 @@ function playbackGeometry( }) } -function createPlaybackControls(document: Document) { - const toolbar = document.createElement('div') - toolbar.className = 'ts-conformance-playback-toolbar' - toolbar.setAttribute('role', 'group') - toolbar.setAttribute('aria-label', 'Timeline playback controls') - Object.assign(toolbar.style, { - position: 'absolute', - top: '4px', - left: '56px', - right: '20px', - zIndex: '3', - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - gap: '8px', - pointerEvents: 'none', - }) - - const current = document.createElement('div') - current.className = 'ts-conformance-playback-current' - Object.assign(current.style, { - boxSizing: 'border-box', - minWidth: '0', - minHeight: '32px', - padding: '7px 9px', - border: '1px solid color-mix(in srgb, currentColor 32%, transparent)', - borderRadius: '999px', - overflow: 'hidden', - background: 'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)', - color: 'inherit', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - font: '600 12px/1.2 system-ui, sans-serif', - }) - - const playButton = document.createElement('button') - playButton.className = 'ts-conformance-playback-button' - playButton.type = 'button' - Object.assign(playButton.style, { - flex: '0 0 auto', - width: '44px', - height: '44px', - border: '1px solid color-mix(in srgb, currentColor 32%, transparent)', - borderRadius: '10px', - background: 'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)', - color: 'inherit', - cursor: 'pointer', - font: '700 16px/1 system-ui, sans-serif', - pointerEvents: 'auto', - }) - - const status = document.createElement('output') - status.className = 'ts-conformance-playback-announcement' - status.setAttribute('role', 'status') - status.setAttribute('aria-live', 'polite') - status.setAttribute('aria-atomic', 'true') - Object.assign(status.style, { - position: 'absolute', - width: '1px', - height: '1px', - padding: '0', - margin: '-1px', - overflow: 'hidden', - clipPath: 'inset(50%)', - whiteSpace: 'nowrap', - }) - toolbar.append(current, playButton) - - return { - toolbar, - status, - playButton, - paint(valueText: string, playing: boolean) { - current.textContent = valueText - playButton.textContent = playing ? '❚❚' : '▶' - playButton.setAttribute('aria-pressed', String(playing)) - playButton.setAttribute( - 'aria-label', - playing ? 'Pause timeline' : 'Play timeline', - ) - playButton.title = playing ? 'Pause timeline' : 'Play timeline' - }, - announce(message: string) { - status.value = message - status.textContent = message - }, - } -} - function elementGeometry(element: SVGElement): ConformanceGeometrySample { const bounds = element.getBoundingClientRect() const style = getComputedStyle(element) @@ -474,7 +254,7 @@ function elementGeometry(element: SVGElement): ConformanceGeometrySample { } } -function rowForDate(date: Date) { +export function rowForDate(date: Date) { const row = playbackRows.find( (candidate) => candidate.Date.getTime() === date.getTime(), ) @@ -482,7 +262,7 @@ function rowForDate(date: Date) { return row } -function indexForDate(date: Date) { +export function indexForDate(date: Date) { const index = playbackRows.findIndex( (row) => row.Date.getTime() === date.getTime(), ) @@ -490,11 +270,11 @@ function indexForDate(date: Date) { return index } -function playbackValueText(row: AaplRow) { +export function playbackValueText(row: AaplRow) { return `${playbackDateKey(row.Date)} · AAPL close $${row.Close.toFixed(2)}` } -function cloneDate(date: Date) { +export function cloneDate(date: Date) { return new Date(date.getTime()) } @@ -506,8 +286,3 @@ function center(element: HTMLElement | SVGElement) { focusElement: element, } } - -function sizeView(view: HTMLDivElement, input: ConformanceInput) { - view.style.width = `${input.width}px` - view.style.height = `${input.height}px` -} diff --git a/benchmarks/conformance/cases/91-timeline-playback-scrubber/view.tsx b/benchmarks/conformance/cases/91-timeline-playback-scrubber/view.tsx new file mode 100644 index 00000000..0b24fbea --- /dev/null +++ b/benchmarks/conformance/cases/91-timeline-playback-scrubber/view.tsx @@ -0,0 +1,263 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { Chart } from '@tanstack/charts/react' +import { reactMount } from '../../shared/react-mount' +import { + cloneDate, + createDriver, + indexForDate, + initialFrame, + playbackDefinition, + playbackRows, + playbackValueText, + rowForDate, +} from './tanstack' +import type { AaplRow } from '@charts-poc/demo-data/aapl' +import type { ChartScene } from '@tanstack/charts' +import type { HandleXChange } from '@tanstack/charts/interaction/handle' +import type { ReactConformanceProps } from '../../shared/react-mount' +import type { ConformanceTestDriver } from '../../types' +import type { PlaybackState } from './tanstack' + +const PlaybackExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function PlaybackExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const chartRef = useRef(null) + const playRef = useRef(null) + const sceneRef = useRef>(null) + const timerRef = useRef | undefined>(undefined) + const [accepted, setAccepted] = useState(() => cloneDate(initialFrame)) + const [state, setState] = useState(() => ({ + frame: cloneDate(initialFrame), + dragging: false, + scrubCount: 0, + playing: false, + })) + const [announcement, setAnnouncement] = useState('') + const stateRef = useRef(state) + stateRef.current = state + + const commitState = useCallback((next: PlaybackState) => { + stateRef.current = next + setState(next) + }, []) + const frameText = useCallback( + (frame = stateRef.current.frame) => playbackValueText(rowForDate(frame)), + [], + ) + const stopPlayback = useCallback( + (message?: string) => { + if (timerRef.current !== undefined) clearInterval(timerRef.current) + timerRef.current = undefined + commitState({ ...stateRef.current, playing: false }) + if (message) setAnnouncement(`${message}. ${frameText()}`) + }, + [commitState, frameText], + ) + const applyFrame = useCallback( + (next: Date) => { + const frame = cloneDate(next) + setAccepted(frame) + commitState({ ...stateRef.current, frame }) + }, + [commitState], + ) + const handleFrameChange = useCallback( + (next: Date, reason: HandleXChange) => { + if (stateRef.current.playing) stopPlayback() + if (reason.type === 'preview') { + commitState({ + ...stateRef.current, + frame: cloneDate(next), + dragging: true, + }) + return + } + if (reason.type === 'cancel') { + const frame = cloneDate(reason.origin) + commitState({ ...stateRef.current, frame, dragging: false }) + setAnnouncement(`Scrub canceled. ${frameText(frame)}`) + return + } + const frame = cloneDate(next) + setAccepted(frame) + commitState({ + ...stateRef.current, + frame, + dragging: false, + scrubCount: stateRef.current.scrubCount + 1, + }) + setAnnouncement(`Frame selected. ${frameText(frame)}`) + }, + [commitState, frameText, stopPlayback], + ) + const definition = useMemo( + () => playbackDefinition(accepted, handleFrameChange), + [accepted, handleFrameChange], + ) + + const togglePlayback = useCallback(() => { + if (stateRef.current.playing) { + stopPlayback('Playback paused') + return + } + const lastIndex = playbackRows.length - 1 + const restarting = indexForDate(stateRef.current.frame) >= lastIndex + if (restarting) applyFrame(playbackRows[0]!.Date) + commitState({ ...stateRef.current, playing: true, dragging: false }) + timerRef.current = setInterval(() => { + const index = indexForDate(stateRef.current.frame) + if (index >= playbackRows.length - 1) { + stopPlayback('Playback ended') + return + } + applyFrame(playbackRows[index + 1]!.Date) + }, 700) + setAnnouncement( + `${restarting ? 'Playback restarted' : 'Playback started'}. ${frameText()}`, + ) + }, [applyFrame, commitState, frameText, stopPlayback]) + + useEffect( + () => () => { + if (timerRef.current !== undefined) clearInterval(timerRef.current) + }, + [], + ) + useImperativeHandle(ref, () => { + const view = viewRef.current + const chart = chartRef.current + const play = playRef.current + if (!view || !chart || !play) throw new Error('Missing playback view') + return createDriver( + view, + chart, + play, + () => { + if (!sceneRef.current) throw new Error('Missing playback scene') + return sceneRef.current + }, + () => stateRef.current, + ) + }, []) + + const buttonLabel = state.playing ? 'Pause timeline' : 'Play timeline' + return ( +
+
+ { + sceneRef.current = scene + }} + /> +
+
+
+ {frameText(state.frame)} +
+ +
+ + {announcement} + +
+ ) +}) + +export const mount = reactMount(PlaybackExample) diff --git a/benchmarks/conformance/cases/92-editable-event-range/controls.ts b/benchmarks/conformance/cases/92-editable-event-range/controls.ts deleted file mode 100644 index 96136656..00000000 --- a/benchmarks/conformance/cases/92-editable-event-range/controls.ts +++ /dev/null @@ -1,190 +0,0 @@ -export interface EditableControlsState { - date: string - minDate: string - maxDate: string - summaryText: string - eventDescriptions: readonly string[] -} - -export interface EditableControlsOptions { - onDateInput: (value: string) => boolean - onDateCommit: () => void - onDateCancel: () => void -} - -export function createEditableControls( - view: HTMLDivElement, - options: EditableControlsOptions, -) { - const document = view.ownerDocument - const style = document.createElement('style') - style.textContent = ` - .ts-conformance-event-date:focus-visible { - outline: 3px solid var(--ts-chart-1, #2563eb); - outline-offset: 2px; - } - .ts-conformance-event-summary, - .ts-conformance-event-date { - border: 1px solid color-mix(in srgb, currentColor 32%, transparent); - background: color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas); - color: inherit; - } - .ts-conformance-event-date { - color-scheme: light dark; - } - .ts-conformance-event-date[aria-invalid="true"] { - border-color: #dc2626; - } - ` - - const layer = document.createElement('div') - Object.assign(layer.style, { - position: 'absolute', - inset: '0', - zIndex: '3', - pointerEvents: 'none', - }) - - const toolbar = document.createElement('div') - toolbar.className = 'ts-conformance-event-toolbar' - toolbar.setAttribute('role', 'group') - toolbar.setAttribute('aria-label', 'Release event editor') - Object.assign(toolbar.style, { - position: 'absolute', - top: '4px', - left: '12px', - right: '12px', - display: 'flex', - flexWrap: 'wrap', - alignItems: 'flex-end', - justifyContent: 'flex-end', - gap: '8px', - color: 'inherit', - pointerEvents: 'none', - }) - - const status = document.createElement('output') - status.className = 'ts-conformance-event-summary' - status.setAttribute('role', 'status') - status.setAttribute('aria-live', 'polite') - status.setAttribute('aria-atomic', 'true') - Object.assign(status.style, { - boxSizing: 'border-box', - flex: '1 1 120px', - minWidth: '120px', - minHeight: '44px', - padding: '8px 10px', - borderRadius: '10px', - display: 'flex', - alignItems: 'center', - font: '600 12px/1.25 system-ui, sans-serif', - }) - - const dateLabel = document.createElement('label') - Object.assign(dateLabel.style, { - boxSizing: 'border-box', - flex: '0 1 140px', - minWidth: '128px', - display: 'grid', - gap: '2px', - color: 'inherit', - font: '600 11px/1.15 system-ui, sans-serif', - pointerEvents: 'auto', - }) - dateLabel.append('Release end') - - const dateInput = document.createElement('input') - dateInput.className = 'ts-conformance-event-date' - dateInput.type = 'date' - dateInput.required = true - dateInput.setAttribute('aria-label', 'Release end date input') - dateInput.setAttribute('aria-invalid', 'false') - Object.assign(dateInput.style, { - boxSizing: 'border-box', - width: '100%', - height: '44px', - padding: '6px 8px', - borderRadius: '8px', - font: '600 12px/1 system-ui, sans-serif', - }) - dateLabel.append(dateInput) - - const validation = document.createElement('span') - validation.className = 'ts-conformance-event-validation' - validation.setAttribute('aria-live', 'polite') - validation.hidden = true - Object.assign(validation.style, { - flex: '1 0 100%', - color: '#dc2626', - font: '600 11px/1.2 system-ui, sans-serif', - }) - - const eventList = document.createElement('ul') - eventList.className = 'ts-conformance-event-identities' - Object.assign(eventList.style, { - position: 'absolute', - width: '1px', - height: '1px', - padding: '0', - margin: '-1px', - overflow: 'hidden', - clipPath: 'inset(50%)', - whiteSpace: 'nowrap', - }) - - const setDateValidity = (valid: boolean) => { - const message = valid ? '' : 'Choose a release end date within the range.' - dateInput.setAttribute('aria-invalid', String(!valid)) - dateInput.setCustomValidity(message) - validation.hidden = valid - validation.textContent = message - } - const handleDateInput = () => { - setDateValidity(options.onDateInput(dateInput.value)) - } - const handleDateCommit = () => { - if (dateInput.getAttribute('aria-invalid') !== 'true') { - options.onDateCommit() - } - } - const handleDateCancel = () => options.onDateCancel() - dateInput.addEventListener('input', handleDateInput) - dateInput.addEventListener('change', handleDateCommit) - dateInput.addEventListener('pointercancel', handleDateCancel) - - toolbar.append(status, dateLabel, validation) - layer.append(toolbar, eventList) - view.append(style, layer) - - return { - dateInput, - paint(state: EditableControlsState) { - dateInput.min = state.minDate - dateInput.max = state.maxDate - if ( - document.activeElement !== dateInput || - dateInput.getAttribute('aria-invalid') !== 'true' - ) { - dateInput.value = state.date - setDateValidity(true) - } - - status.value = state.summaryText - status.textContent = state.summaryText - eventList.replaceChildren( - ...state.eventDescriptions.map((description) => { - const item = document.createElement('li') - item.textContent = description - return item - }), - ) - }, - destroy() { - dateInput.removeEventListener('input', handleDateInput) - dateInput.removeEventListener('change', handleDateCommit) - dateInput.removeEventListener('pointercancel', handleDateCancel) - style.remove() - layer.remove() - }, - } -} diff --git a/benchmarks/conformance/cases/92-editable-event-range/tanstack.test.ts b/benchmarks/conformance/cases/92-editable-event-range/tanstack.test.ts index 18eb43a3..8080ddaf 100644 --- a/benchmarks/conformance/cases/92-editable-event-range/tanstack.test.ts +++ b/benchmarks/conformance/cases/92-editable-event-range/tanstack.test.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { createChartRuntime } from '@tanstack/charts' +import { act } from 'react' import { describe, expect, expectTypeOf, it } from 'vitest' import { editableDateKey, editableEventEndValues } from './model' import { editableEventDefinition, mount } from './tanstack' @@ -52,7 +53,10 @@ describe('definition-owned editable event handle', () => { it('uses the first-party handle while date validation stays application-owned', () => { const container = document.createElement('div') document.body.append(container) - const mounted = mount(container, input) + let mounted!: ReturnType + act(() => { + mounted = mount(container, input) + }) const driver = mounted.driver const handle = container.querySelector( '[data-chart-handle-surface="release-end"]', @@ -74,13 +78,15 @@ describe('definition-owned editable event handle', () => { }) handle.focus() - handle.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }), - ) + act(() => { + handle.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }), + ) + }) expect(driver.readState()).toMatchObject({ editor: { end: '2025-02-13', @@ -93,13 +99,17 @@ describe('definition-owned editable event handle', () => { dateInput.focus() dateInput.value = '' - dateInput.dispatchEvent(new Event('input', { bubbles: true })) + act(() => { + dateInput.dispatchEvent(new Event('input', { bubbles: true })) + }) expect(dateInput.getAttribute('aria-invalid')).toBe('true') expect(driver.readState()).toMatchObject({ editor: { end: '2025-02-13', editCount: 1 }, }) - mounted.destroy() + act(() => { + mounted.destroy() + }) expect(container.childElementCount).toBe(0) container.remove() }) @@ -110,9 +120,10 @@ describe('definition-owned editable event handle', () => { 'benchmarks/conformance/cases/92-editable-event-range', ) const source = readFileSync(resolve(directory, 'tanstack.ts'), 'utf8') + const view = readFileSync(resolve(directory, 'view.tsx'), 'utf8') expect(existsSync(resolve(directory, 'overlay.ts'))).toBe(true) - expect(existsSync(resolve(directory, 'controls.ts'))).toBe(true) + expect(existsSync(resolve(directory, 'controls.ts'))).toBe(false) for (const forbidden of [ "from './overlay'", 'createEditableHandleOverlay', @@ -132,6 +143,7 @@ describe('definition-owned editable event handle', () => { expect(source).toContain('handleX({') expect(source).toContain('controlledSignal<') expect(source).toContain('(next, { reason }) => onEndChange(next, reason)') - expect(source).toContain("from './controls'") + expect(view).toContain("from '@tanstack/charts/react'") + expect(view).toContain('className="ts-conformance-event-date"') }) }) diff --git a/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts b/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts index 66c6acf9..54ea79fb 100644 --- a/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts +++ b/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts @@ -1,9 +1,8 @@ -import { defineChart, mountChart, rect, text } from '@tanstack/charts' +import { defineChart, rect, text } from '@tanstack/charts' import { handleX } from '@tanstack/charts/interaction/handle' import { controlledSignal } from '@tanstack/charts/interaction/signal' import { scaleBand, scaleUtc } from 'd3-scale' import { editableEventColor } from './colors' -import { createEditableControls } from './controls' import { clampEditableEventEnd, editableDateFromAnchor, @@ -20,23 +19,22 @@ import { } from './scenario' import { scenePointToClient } from '../../shared/driver-geometry' import { tanstackCase } from '../../shared/mount' -import type { ChartHost, ChartHostOptions, ChartScene } from '@tanstack/charts' +import type { ChartScene } from '@tanstack/charts' import type { HandleXChange } from '@tanstack/charts/interaction/handle' import type { EditableEvent } from './scenario' import type { ConformanceGeometryQuery, ConformanceGeometrySample, ConformanceInput, - ConformanceMount, ConformanceTarget, ConformanceTestDriver, } from '../../types' -interface EditableChartInput extends ConformanceInput { +export interface EditableChartInput extends ConformanceInput { end: Date } -interface EditableState { +export interface EditableState { end: Date editing: boolean editCount: number @@ -175,143 +173,9 @@ export const catalogCase = tanstackCase( editableAriaLabel(0, initialEditableEventEnd), ) -export const mount: ConformanceMount = (container, input) => { - let currentInput = input - let acceptedEnd = cloneDate(initialEditableEventEnd) - let host: ChartHost | undefined - const state: EditableState = { - end: cloneDate(acceptedEnd), - editing: false, - editCount: 0, - originEnd: null, - } - - const document = container.ownerDocument - const view = document.createElement('div') - const chartSurface = document.createElement('div') - view.dataset.conformanceView = 'main' - view.style.position = 'relative' - view.style.touchAction = 'pan-y' - view.append(chartSurface) - container.append(view) - sizeView(view, input) - - const beginEdit = (origin = state.end) => { - if (state.editing) return - state.originEnd = cloneDate(origin) - state.editing = true - } - - const options = (): ChartHostOptions< - EditableEvent, - Date | number, - string - > => ({ - definition: editableEventDefinition( - { ...currentInput, end: acceptedEnd }, - handleEndChange, - ), - width: currentInput.width, - height: currentInput.height, - ariaLabel: editableAriaLabel(currentInput.revision, state.end), - }) - - const applyEnd = (next: Date) => { - acceptedEnd = clampEditableEventEnd(next) - state.end = cloneDate(acceptedEnd) - host?.update(options()) - } +export { mount } from './view' - const commitEdit = () => { - if (!state.editing) return - state.editing = false - state.originEnd = null - state.editCount += 1 - paintControls() - } - - const cancelEdit = (fallback?: Date) => { - if (!state.editing && !fallback) return - const origin = fallback ?? state.originEnd - state.editing = false - state.originEnd = null - if (origin) applyEnd(origin) - paintControls() - } - - function handleEndChange(next: Date, reason: HandleXChange) { - if (reason.type === 'preview') { - beginEdit(reason.origin) - applyEnd(next) - paintControls() - return - } - if (reason.type === 'cancel') { - cancelEdit(reason.origin) - return - } - beginEdit(reason.origin) - applyEnd(next) - commitEdit() - } - - const controls = createEditableControls(view, { - onDateInput(value) { - const next = editableDateFromAnchor(`date:${value}`) - if (!next || clampEditableEventEnd(next).getTime() !== next.getTime()) { - return false - } - beginEdit() - applyEnd(next) - paintControls() - return true - }, - onDateCommit: commitEdit, - onDateCancel: () => cancelEdit(), - }) - - function paintControls() { - controls.paint({ - date: editableDateKey(state.end), - minDate: editableDateKey(editableEventEndValues[0]!), - maxDate: editableDateKey(editableEventEndValues.at(-1)!), - summaryText: editableSummaryText(state.end), - eventDescriptions: editableEvents(currentInput.revision, state.end).map( - (row) => - `${row.label}: ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`, - ), - }) - } - - host = mountChart(chartSurface, options()) - paintControls() - - const driver = createDriver( - view, - chartSurface, - controls.dateInput, - () => host!.getScene(), - () => state, - () => currentInput, - ) - - return { - driver, - update(nextInput) { - currentInput = nextInput - sizeView(view, nextInput) - host!.update(options()) - paintControls() - }, - destroy() { - controls.destroy() - host!.destroy() - view.remove() - }, - } -} - -function createDriver( +export function createDriver( view: HTMLDivElement, chartSurface: HTMLDivElement, dateInput: HTMLInputElement, @@ -484,16 +348,11 @@ function elementCenter(element: HTMLElement | SVGElement) { } } -function sizeView(view: HTMLDivElement, input: ConformanceInput) { - view.style.width = `${input.width}px` - view.style.height = `${input.height}px` -} - function editableHandleValueText(end: Date) { return `Release: ${editableDateKey(editableEventStart)} → ${editableDateKey(end)} · ${editableDurationDays(editableEventStart, end)} days` } -function editableSummaryText(end: Date) { +export function editableSummaryText(end: Date) { return `Release · ${compactDate(editableEventStart)} → ${compactDate(end)} · ${editableDurationDays(editableEventStart, end)} days` } @@ -505,7 +364,7 @@ function compactDate(date: Date) { }) } -function editableAriaLabel(revision: number, end: Date) { +export function editableAriaLabel(revision: number, end: Date) { return `Editable schedule. ${editableEvents(revision, end) .map( (row) => @@ -526,6 +385,6 @@ function eventBarCanFitLabel( return barWidth >= label.length * 6 + 10 } -function cloneDate(date: Date) { +export function cloneDate(date: Date) { return new Date(date.getTime()) } diff --git a/benchmarks/conformance/cases/92-editable-event-range/view.tsx b/benchmarks/conformance/cases/92-editable-event-range/view.tsx new file mode 100644 index 00000000..c1b2a718 --- /dev/null +++ b/benchmarks/conformance/cases/92-editable-event-range/view.tsx @@ -0,0 +1,348 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { Chart } from '@tanstack/charts/react' +import { reactMount } from '../../shared/react-mount' +import { + clampEditableEventEnd, + editableDateFromAnchor, + editableDateKey, + editableEventEndValues, +} from './model' +import { editableEvents, initialEditableEventEnd } from './scenario' +import { + cloneDate, + createDriver, + editableAriaLabel, + editableEventDefinition, + editableSummaryText, +} from './tanstack' +import type { ChartScene } from '@tanstack/charts' +import type { HandleXChange } from '@tanstack/charts/interaction/handle' +import type { FormEvent, KeyboardEvent, PointerEvent } from 'react' +import type { ReactConformanceProps } from '../../shared/react-mount' +import type { ConformanceTestDriver } from '../../types' +import type { EditableEvent } from './scenario' +import type { EditableState } from './tanstack' + +const validationMessage = 'Choose a release end date within the range.' + +const EditableEventExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function EditableEventExample({ input, idPrefix }, ref) { + const viewRef = useRef(null) + const chartRef = useRef(null) + const dateRef = useRef(null) + const sceneRef = useRef | null>(null) + const inputRef = useRef(input) + inputRef.current = input + const [acceptedEnd, setAcceptedEnd] = useState(() => + cloneDate(initialEditableEventEnd), + ) + const [state, setState] = useState(() => ({ + end: cloneDate(initialEditableEventEnd), + editing: false, + editCount: 0, + originEnd: null, + })) + const [dateValue, setDateValue] = useState(() => + editableDateKey(initialEditableEventEnd), + ) + const [invalid, setInvalid] = useState(false) + const stateRef = useRef(state) + stateRef.current = state + + const commitState = useCallback((next: EditableState) => { + stateRef.current = next + setState(next) + }, []) + const beginEdit = useCallback( + (origin = stateRef.current.end) => { + if (stateRef.current.editing) return + commitState({ + ...stateRef.current, + originEnd: cloneDate(origin), + editing: true, + }) + }, + [commitState], + ) + const applyEnd = useCallback( + (next: Date) => { + const end = clampEditableEventEnd(next) + setAcceptedEnd(end) + setDateValue(editableDateKey(end)) + setInvalid(false) + commitState({ ...stateRef.current, end: cloneDate(end) }) + }, + [commitState], + ) + const commitEdit = useCallback(() => { + if (!stateRef.current.editing) return + commitState({ + ...stateRef.current, + editing: false, + originEnd: null, + editCount: stateRef.current.editCount + 1, + }) + }, [commitState]) + const cancelEdit = useCallback( + (fallback?: Date) => { + if (!stateRef.current.editing && !fallback) return + const origin = fallback ?? stateRef.current.originEnd + const end = origin ? clampEditableEventEnd(origin) : stateRef.current.end + setAcceptedEnd(end) + setDateValue(editableDateKey(end)) + setInvalid(false) + commitState({ + ...stateRef.current, + end: cloneDate(end), + editing: false, + originEnd: null, + }) + }, + [commitState], + ) + const handleEndChange = useCallback( + (next: Date, reason: HandleXChange) => { + if (reason.type === 'preview') { + beginEdit(reason.origin) + applyEnd(next) + return + } + if (reason.type === 'cancel') { + cancelEdit(reason.origin) + return + } + beginEdit(reason.origin) + applyEnd(next) + commitEdit() + }, + [applyEnd, beginEdit, cancelEdit, commitEdit], + ) + const definition = useMemo( + () => + editableEventDefinition({ ...input, end: acceptedEnd }, handleEndChange), + [acceptedEnd, handleEndChange, input], + ) + + useEffect(() => { + dateRef.current?.setCustomValidity(invalid ? validationMessage : '') + }, [invalid]) + useImperativeHandle(ref, () => { + const view = viewRef.current + const chart = chartRef.current + const date = dateRef.current + if (!view || !chart || !date) throw new Error('Missing editable event view') + return createDriver( + view, + chart, + date, + () => { + if (!sceneRef.current) throw new Error('Missing editable event scene') + return sceneRef.current + }, + () => stateRef.current, + () => inputRef.current, + ) + }, []) + + const handleDateInput = (event: FormEvent) => { + const value = event.currentTarget.value + setDateValue(value) + const next = editableDateFromAnchor(`date:${value}`) + if (!next || clampEditableEventEnd(next).getTime() !== next.getTime()) { + setInvalid(true) + return + } + beginEdit() + applyEnd(next) + } + const handleDateKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !invalid) commitEdit() + if (event.key === 'Escape') cancelEdit() + } + const handlePointerCancel = (_event: PointerEvent) => { + cancelEdit() + } + + const minDate = editableDateKey(editableEventEndValues[0]!) + const maxDate = editableDateKey(editableEventEndValues.at(-1)!) + const eventDescriptions = editableEvents(input.revision, state.end).map( + (row) => + `${row.label}: ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`, + ) + return ( +
+ +
+ { + sceneRef.current = scene + }} + /> +
+
+
+ + {editableSummaryText(state.end)} + + + +
+
    + {eventDescriptions.map((description) => ( +
  • {description}
  • + ))} +
+
+
+ ) +}) + +export const mount = reactMount(EditableEventExample) diff --git a/benchmarks/conformance/shared/react-controls.tsx b/benchmarks/conformance/shared/react-controls.tsx new file mode 100644 index 00000000..7c6af895 --- /dev/null +++ b/benchmarks/conformance/shared/react-controls.tsx @@ -0,0 +1,96 @@ +import { forwardRef, type ReactNode } from 'react' + +export const ControlBar = forwardRef< + HTMLDivElement, + { label: string; children: ReactNode } +>(function ControlBar({ label, children }, ref) { + return ( +
+ {children} +
+ ) +}) + +export function ControlField({ + children, + label, +}: { + children: ReactNode + label: string +}) { + return ( + + ) +} + +export const ControlButton = forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>(function ControlButton({ children, style, type = 'button', ...props }, ref) { + return ( + + ) +}) + +export function RangeField({ + label, + max, + min, + onChange, + step, + suffix = '', + value, +}: { + label: string + max: number + min: number + onChange: (value: number) => void + step: number + suffix?: string + value: number +}) { + return ( + + onChange(Number(event.currentTarget.value))} + style={{ width: 96 }} + /> + {`${value}${suffix}`} + + ) +} diff --git a/examples/conformance/index.html b/examples/conformance/index.html index 24203252..36f5e8aa 100644 --- a/examples/conformance/index.html +++ b/examples/conformance/index.html @@ -21,6 +21,6 @@
- + diff --git a/examples/conformance/src/main.ts b/examples/conformance/src/main.ts deleted file mode 100644 index 65e7089f..00000000 --- a/examples/conformance/src/main.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { - conformanceCases, - getConformanceReferenceRenderer, -} from '../../../benchmarks/conformance/catalog' -import { - loadTanStackImplementation, - loadTanStackSources, -} from '../../../benchmarks/conformance/native-catalog' -import type { - ConformanceCaseMeta, - ConformanceHandle, - ConformanceReferenceRenderer, - ConformanceRenderer, -} from '../../../benchmarks/conformance/types' -import { - catalogRenderers, - isCatalogComparisonMode, - withCatalogComparisonMode, -} from './catalog-mode' -import { - createChartEmbedStatusMessage, - parseChartEmbedHeight, - parseChartEmbedRevision, - parseChartEmbedTheme, - readTrustedChartEmbedThemeCommand, - resolveChartEmbedParentOrigin, - type ChartEmbedStatus, -} from './embed-contract' -import { - catalogRouteHref, - parseCatalogRoute, - type CatalogRoute, -} from './routes' -import { renderCatalogSourceView } from './source-view' -import './styles.css' - -const app = requireApp() -const basePath = import.meta.env.BASE_URL -const chartHeight = 480 -const casesById = new Map(conformanceCases.map((entry) => [entry.id, entry])) -const families = [ - ...new Set(conformanceCases.map((entry) => entry.family)), -].sort((left, right) => left.localeCompare(right)) -const mounted = new Map() -const cleanup = new Set<() => void>() -let comparisonCatalogPromise: - | Promise - | undefined - -let chartWidth = 640 -let revision = 0 -let dark = window.matchMedia('(prefers-color-scheme: dark)').matches -let search = '' -let family = 'all' -let routeGeneration = 0 -let comparisonGeneration = 0 - -app.addEventListener('click', handleRouteLink) -window.addEventListener('popstate', () => void renderRoute()) -applyTheme() -void renderRoute() - -async function renderRoute() { - const generation = ++routeGeneration - comparisonGeneration += 1 - destroyMountedCharts() - destroyCleanup() - document.body.classList.remove('embed-mode') - document.documentElement.classList.remove('embed-mode') - applyTheme() - - const route = parseCatalogRoute(window.location.pathname, basePath) - - if (route.view === 'index') { - renderCatalogIndex() - return - } - - if (route.view === 'all') { - renderAllCases() - await renderComparisonCases(generation) - return - } - - if (route.view === 'case') { - const entry = casesById.get(route.caseId) - if (!entry) { - renderNotFound() - return - } - renderCasePage(entry) - await renderComparisonCases(generation, [entry]) - return - } - - if (route.view === 'embed') { - const entry = casesById.get(route.caseId) - if (!entry) { - renderNotFound(true) - return - } - await renderEmbed(entry, generation) - return - } - - renderNotFound() -} - -function renderCatalogIndex() { - const comparisonMode = comparisonModeEnabled() - setDocumentMeta( - 'TanStack Charts Catalog', - comparisonMode - ? 'Browse executable chart examples, conformance comparisons, source, and documentation-ready TanStack Charts proofs.' - : 'Browse executable TanStack Charts examples, source, and documentation-ready proofs.', - ) - - app.innerHTML = ` - ${renderSiteHeader('catalog')} -
-
-

TanStack Charts · executable catalog

-

Proof, case by case.

-

- ${ - comparisonMode - ? 'Browse typed examples, inspect source, compare established references, and reference any TanStack chart from documentation.' - : 'Browse typed examples, inspect source, and reference any TanStack chart from documentation.' - } -

-
- ${renderSummary()} -
- ${renderBrowseToolbar()} -
- -
-
- ` - - bindBrowseControls(renderCatalogCards) - renderCatalogCards() -} - -function renderAllCases() { - const comparisonMode = comparisonModeEnabled() - setDocumentMeta( - 'All charts · TanStack Charts Catalog', - comparisonMode - ? 'Render the complete TanStack Charts conformance catalog alongside its source-library references.' - : 'Render the complete TanStack Charts catalog with live source and controls.', - ) - - app.innerHTML = ` - ${renderSiteHeader('all')} -
-
-

${comparisonMode ? 'Complete comparison surface' : 'Complete catalog'}

-

Every chart, one page.

- ${ - comparisonMode - ? `

- Same inputs and intent, with both renderers exposed for direct - geometry, source, performance, and output comparison. -

` - : '' - } -
- ${renderSummary()} -
- ${renderComparisonToolbar()} -
- ` - - bindBrowseControls(() => void renderComparisonCases(routeGeneration)) - bindComparisonControls() -} - -function renderCasePage(entry: ConformanceCaseMeta) { - const index = conformanceCases.indexOf(entry) - const previous = conformanceCases[index - 1] - const next = conformanceCases[index + 1] - const comparisonMode = comparisonModeEnabled() - - setDocumentMeta( - `${entry.title} · TanStack Charts Catalog`, - comparisonMode - ? `${entry.intent} Compare the reference implementation with TanStack Charts, inspect source, or reference the chart in documentation.` - : `${entry.intent} Inspect the source or reference the chart in documentation.`, - ) - - app.innerHTML = ` - ${renderSiteHeader()} -
-
- - ← Catalog - -

${String(entry.order).padStart(2, '0')} · ${escapeHtml(entry.family)}

-

${escapeHtml(entry.title)}

-

${escapeHtml(entry.intent)}

-
- -
- ${renderComparisonToolbar(false)} -
- ` - - bindComparisonControls() -} - -async function renderEmbed(entry: ConformanceCaseMeta, generation: number) { - document.body.classList.add('embed-mode') - document.documentElement.classList.add('embed-mode') - - const params = new URLSearchParams(window.location.search) - const height = parseChartEmbedHeight(params.get('height')) - const embedRevision = parseChartEmbedRevision(params.get('revision')) - const preview = params.get('preview') === '1' - let theme = parseChartEmbedTheme(params.get('theme')) - const parentOrigin = resolveChartEmbedParentOrigin(document.referrer) - const media = window.matchMedia('(prefers-color-scheme: dark)') - const applyEmbedTheme = () => { - const embedDark = theme === 'dark' || (theme !== 'light' && media.matches) - document.documentElement.dataset.theme = embedDark ? 'dark' : 'light' - } - const handleMediaChange = () => { - if (theme === 'system') applyEmbedTheme() - } - const handleParentMessage = (event: MessageEvent) => { - const command = readTrustedChartEmbedThemeCommand( - event, - window.parent, - parentOrigin, - entry.id, - ) - if (!command) return - theme = command.theme - applyEmbedTheme() - } - - applyEmbedTheme() - media.addEventListener('change', handleMediaChange) - window.addEventListener('message', handleParentMessage) - cleanup.add(() => media.removeEventListener('change', handleMediaChange)) - cleanup.add(() => window.removeEventListener('message', handleParentMessage)) - - setDocumentMeta(`${entry.title} · TanStack Charts`, entry.intent, true) - - app.innerHTML = ` -
-
-
- ` - - await document.fonts?.ready - if (generation !== routeGeneration) return - - const container = document.getElementById(`${entry.id}-tanstack`) - if (!container) return - - try { - const implementation = await loadTanStackImplementation(entry.id) - if (generation !== routeGeneration) return - if (!implementation) { - throw new Error('This catalog case has no TanStack implementation yet.') - } - - let width = measureEmbedWidth(container) - const mount = - preview && implementation.catalogCase - ? implementation.catalogCase.mount - : implementation.mount - const handle = mount(container, { - width, - height, - revision: embedRevision, - interactive: !preview, - preview, - }) - mounted.set(`${entry.id}:tanstack`, handle) - - const updateWidth = (nextWidth: number) => { - if (nextWidth === width || nextWidth < 1) return - width = nextWidth - handle.update({ - width, - height, - revision: embedRevision, - interactive: !preview, - preview, - }) - postEmbedMessage('resize', entry, height, parentOrigin) - } - - const observer = new ResizeObserver(() => { - updateWidth(measureEmbedWidth(container)) - }) - observer.observe(container) - cleanup.add(() => observer.disconnect()) - - requestAnimationFrame(() => { - postEmbedMessage('ready', entry, height, parentOrigin) - }) - } catch (error) { - renderFailure(container, error) - postEmbedMessage('error', entry, height, parentOrigin) - } -} - -function renderCatalogCards() { - const entries = filterCases() - const grid = document.querySelector('#catalog-grid') - const count = document.querySelector('#result-count') - if (!grid || !count) return - - count.textContent = `${entries.length} of ${conformanceCases.length} cases` - grid.innerHTML = entries.length - ? entries.map(renderCatalogCard).join('') - : '

No catalog cases match those filters.

' -} - -async function renderComparisonCases( - route: number, - explicitEntries?: ConformanceCaseMeta[], -) { - const generation = ++comparisonGeneration - destroyMountedCharts() - const container = document.querySelector('#cases') - if (!container) return - - const entries = explicitEntries ?? filterCases() - container.innerHTML = entries.length - ? entries.map(renderCaseCard).join('') - : '

No catalog cases match those filters.

' - - await document.fonts?.ready - if ( - generation !== comparisonGeneration || - route !== routeGeneration || - !container.isConnected - ) { - return - } - - await Promise.all(entries.map((entry) => mountCase(entry, route, generation))) -} - -function renderCatalogCard(entry: ConformanceCaseMeta): string { - const comparisonMode = comparisonModeEnabled() - const reference = comparisonMode - ? getConformanceReferenceRenderer(entry) - : undefined - return ` -
-
-

${String(entry.order).padStart(2, '0')} · ${escapeHtml(entry.family)}

- ${entry.support} -
-

- - ${escapeHtml(entry.title)} - -

-

${escapeHtml(entry.intent)}

-
    - ${entry.features.map((feature) => `
  • ${escapeHtml(feature)}
  • `).join('')} -
- -
- ` -} - -function renderCaseCard(entry: ConformanceCaseMeta): string { - const comparisonMode = comparisonModeEnabled() - const referenceRenderer = getConformanceReferenceRenderer(entry) - const docsDirective = `` - - return ` -
-
-
-

${String(entry.order).padStart(2, '0')} · ${escapeHtml(entry.family)}

-

- - ${escapeHtml(entry.title)} - -

-

${escapeHtml(entry.intent)}

-
- ${entry.support} -
-
    - ${entry.features.map((feature) => `
  • ${escapeHtml(feature)}
  • `).join('')} -
-
- ${ - comparisonMode - ? renderRendererPanel( - entry, - referenceRenderer, - rendererLabel(referenceRenderer), - ) - : '' - } - ${renderRendererPanel(entry, 'tanstack', 'TanStack Charts')} -
- -
- ` -} - -function renderRendererPanel( - entry: ConformanceCaseMeta, - renderer: ConformanceRenderer, - label: string, -): string { - return ` -
-
-

${escapeHtml(label)}

- pending -
-
-
- Source -
loading…
-
-
- ` -} - -async function mountCase( - entry: ConformanceCaseMeta, - route: number, - comparison: number, -) { - const referenceRenderer = getConformanceReferenceRenderer(entry) - await Promise.all( - catalogRenderers(referenceRenderer, comparisonModeEnabled()).map( - (renderer) => mountRenderer(entry, renderer, route, comparison), - ), - ) -} - -async function mountRenderer( - entry: ConformanceCaseMeta, - renderer: ConformanceRenderer, - route: number, - comparison: number, -) { - const key = `${entry.id}:${renderer}` - const container = document.getElementById(`${entry.id}-${renderer}`) - const metrics = document.getElementById(`${entry.id}-${renderer}-metrics`) - const sourceElement = document.getElementById( - `${entry.id}-${renderer}-source`, - ) - if (!container) return - - try { - const [implementation, source] = - renderer === 'tanstack' - ? await Promise.all([ - loadTanStackImplementation(entry.id), - loadTanStackSources(entry.id), - ]) - : await loadComparisonRenderer(entry.id, renderer) - if ( - route !== routeGeneration || - comparison !== comparisonGeneration || - !container.isConnected - ) { - return - } - - if (sourceElement) { - sourceElement.innerHTML = renderCatalogSourceView(source) - } - if (!implementation) { - container.innerHTML = - '

Not implemented. This is a recorded capability gap.

' - if (metrics) metrics.textContent = 'gap' - return - } - - const startedAt = performance.now() - const handle = implementation.mount(container, { - width: chartWidth, - height: chartHeight, - revision, - }) - container.getBoundingClientRect() - const duration = performance.now() - startedAt - mounted.set(key, handle) - if (metrics) { - const elements = container.querySelectorAll('*').length - const svgBytes = [...container.querySelectorAll('svg')].reduce( - (total, svg) => - total + new TextEncoder().encode(svg.outerHTML).byteLength, - 0, - ) - metrics.textContent = `${duration.toFixed(2)} ms · ${elements} nodes · ${formatBytes(svgBytes)} SVG` - } - } catch (error) { - if (route !== routeGeneration || comparison !== comparisonGeneration) return - renderFailure(container, error) - if (metrics) metrics.textContent = 'error' - console.error(`Failed to render ${entry.id} with ${renderer}`, error) - } -} - -async function loadComparisonRenderer( - id: string, - renderer: ConformanceReferenceRenderer, -) { - const catalog = await loadComparisonCatalog() - return Promise.all([ - catalog.loadComparisonImplementation(id, renderer), - catalog.loadComparisonSources(id, renderer), - ]) -} - -function loadComparisonCatalog() { - if (!comparisonModeEnabled()) { - throw new Error('Comparison renderers require ?compare=1.') - } - comparisonCatalogPromise ??= - import('../../../benchmarks/conformance/comparison-catalog') - return comparisonCatalogPromise -} - -function renderSiteHeader(active?: 'catalog' | 'all'): string { - return ` - - ` -} - -function renderSummary(): string { - const native = conformanceCases.filter( - (entry) => entry.support === 'native', - ).length - const composed = conformanceCases.filter( - (entry) => entry.support === 'composed', - ).length - return ` -
-
${conformanceCases.length}
cases
-
${native}
native
-
${composed}
composed
-
- ` -} - -function renderBrowseToolbar(): string { - return ` -
- - - -
- ` -} - -function renderComparisonToolbar(includeFilters = true): string { - return ` -
- ${includeFilters ? renderToolbarFilters() : ''} - - - -
- ` -} - -function renderToolbarFilters(): string { - return ` - - - ` -} - -function bindBrowseControls(onChange: () => void) { - const searchInput = document.querySelector('#search') - const familySelect = document.querySelector('#family') - - searchInput?.addEventListener('input', () => { - search = searchInput.value.trim().toLowerCase() - onChange() - }) - familySelect?.addEventListener('change', () => { - family = familySelect.value - onChange() - }) - bindThemeControl() -} - -function bindComparisonControls() { - const widthSelect = document.querySelector('#width') - const updateButton = document.querySelector('#update') - - widthSelect?.addEventListener('change', () => { - chartWidth = Number(widthSelect.value) - void renderComparisonCases(routeGeneration, explicitRouteCases()) - }) - updateButton?.addEventListener('click', () => { - revision += 1 - updateButton.textContent = `Update data · ${revision}` - updateMountedCharts() - }) - bindThemeControl() -} - -function bindThemeControl() { - const themeButton = document.querySelector('#theme') - themeButton?.addEventListener('click', () => { - dark = !dark - applyTheme() - void renderRoute() - }) -} - -function explicitRouteCases(): ConformanceCaseMeta[] | undefined { - const route = parseCatalogRoute(window.location.pathname, basePath) - if (route.view !== 'case') return undefined - const entry = casesById.get(route.caseId) - return entry ? [entry] : [] -} - -function filterCases(): ConformanceCaseMeta[] { - return conformanceCases.filter((entry) => { - if (family !== 'all' && entry.family !== family) return false - if (!search) return true - const text = [entry.title, entry.family, entry.intent, ...entry.features] - .join(' ') - .toLowerCase() - return text.includes(search) - }) -} - -function renderNotFound(embed = false) { - setDocumentMeta( - 'Chart not found · TanStack Charts Catalog', - 'Chart not found.', - true, - ) - document.body.classList.toggle('embed-mode', embed) - app.innerHTML = ` - ${embed ? '' : renderSiteHeader()} -
-

404

-

Chart not found.

- ${ - embed - ? '' - : `Browse the catalog` - } -
- ` -} - -function handleRouteLink(event: MouseEvent) { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return - } - - const target = event.target - if (!(target instanceof Element)) return - const anchor = target.closest('a[data-catalog-route]') - if ( - !anchor || - anchor.target === '_blank' || - anchor.hasAttribute('download') - ) { - return - } - - const url = new URL(anchor.href) - if (url.origin !== window.location.origin) return - - event.preventDefault() - window.history.pushState({}, '', url) - window.scrollTo({ top: 0 }) - void renderRoute() -} - -function routeHref( - route: Exclude, -): string { - return withCatalogComparisonMode( - catalogRouteHref(route, basePath), - comparisonModeEnabled() && route.view !== 'embed', - ) -} - -function comparisonModeEnabled(): boolean { - return isCatalogComparisonMode(window.location.search) -} - -function rendererLabel(renderer: ConformanceRenderer): string { - if (renderer === 'observable-plot') return 'Observable Plot' - if (renderer === 'recharts') return 'Recharts' - if (renderer === 'echarts') return 'Apache ECharts' - return 'TanStack Charts' -} - -function updateMountedCharts() { - for (const handle of mounted.values()) { - handle.update({ width: chartWidth, height: chartHeight, revision }) - } -} - -function destroyMountedCharts() { - for (const handle of mounted.values()) handle.destroy() - mounted.clear() -} - -function destroyCleanup() { - for (const dispose of cleanup) dispose() - cleanup.clear() -} - -function renderFailure(container: HTMLElement, error: unknown) { - const message = error instanceof Error ? error.message : String(error) - const failure = document.createElement('p') - failure.className = 'gap' - failure.textContent = `Renderer failed: ${message}` - container.replaceChildren(failure) -} - -function setDocumentMeta(title: string, description: string, noIndex = false) { - document.title = title - setMetaContent('description', description) - setMetaContent('robots', noIndex ? 'noindex,follow' : 'index,follow') - setPropertyContent('og:title', title) - setPropertyContent('og:description', description) - setPropertyContent('og:url', canonicalUrl()) - const canonical = document.querySelector( - 'link[rel="canonical"]', - ) - if (canonical) canonical.href = canonicalUrl() -} - -function setMetaContent(name: string, content: string) { - document - .querySelector(`meta[name="${name}"]`) - ?.setAttribute('content', content) -} - -function setPropertyContent(property: string, content: string) { - document - .querySelector(`meta[property="${property}"]`) - ?.setAttribute('content', content) -} - -function canonicalUrl(): string { - return `${window.location.origin}${window.location.pathname}` -} - -function applyTheme() { - document.documentElement.dataset.theme = dark ? 'dark' : 'light' -} - -function postEmbedMessage( - status: ChartEmbedStatus, - entry: ConformanceCaseMeta, - height: number, - parentOrigin: string | null, -) { - if (window.parent === window || !parentOrigin) return - window.parent.postMessage( - createChartEmbedStatusMessage(status, entry.id, height), - parentOrigin, - ) -} - -function measureEmbedWidth(container: HTMLElement): number { - return Math.max(1, Math.floor(container.getBoundingClientRect().width)) -} - -function formatBytes(bytes: number): string { - return bytes < 1_024 ? `${bytes} B` : `${(bytes / 1_024).toFixed(1)} kB` -} - -function escapeHtml(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} - -function requireApp(): HTMLDivElement { - const element = document.querySelector('#app') - if (!element) throw new Error('Missing conformance app root') - return element -} diff --git a/examples/conformance/src/main.tsx b/examples/conformance/src/main.tsx new file mode 100644 index 00000000..6355de43 --- /dev/null +++ b/examples/conformance/src/main.tsx @@ -0,0 +1,1123 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type AnchorHTMLAttributes, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from 'react' +import { createRoot } from 'react-dom/client' +import { + conformanceCases, + getConformanceReferenceRenderer, +} from '../../../benchmarks/conformance/catalog' +import { + loadTanStackImplementation, + loadTanStackSources, +} from '../../../benchmarks/conformance/native-catalog' +import type { + ConformanceCaseMeta, + ConformanceHandle, + ConformanceInput, + ConformanceReferenceRenderer, + ConformanceRenderer, +} from '../../../benchmarks/conformance/types' +import { + catalogRenderers, + isCatalogComparisonMode, + withCatalogComparisonMode, +} from './catalog-mode' +import { + createChartEmbedStatusMessage, + parseChartEmbedHeight, + parseChartEmbedRevision, + parseChartEmbedTheme, + readTrustedChartEmbedThemeCommand, + resolveChartEmbedParentOrigin, + type ChartEmbedStatus, +} from './embed-contract' +import { + catalogRouteHref, + parseCatalogRoute, + type CatalogRoute, +} from './routes' +import { CatalogSourceView } from './source-view' +import './styles.css' + +const basePath = import.meta.env.BASE_URL +const chartHeight = 480 +const casesById = new Map(conformanceCases.map((entry) => [entry.id, entry])) +const families = [ + ...new Set(conformanceCases.map((entry) => entry.family)), +].sort((left, right) => left.localeCompare(right)) +let comparisonCatalogPromise: + | Promise + | undefined + +function CatalogApp() { + const [location, setLocation] = useState(readCatalogLocation) + const [chartWidth, setChartWidth] = useState(640) + const [revision, setRevision] = useState(0) + const [dark, setDark] = useState( + () => window.matchMedia('(prefers-color-scheme: dark)').matches, + ) + const [search, setSearch] = useState('') + const [family, setFamily] = useState('all') + const route = parseCatalogRoute(location.pathname, basePath) + const comparisonMode = isCatalogComparisonMode(location.search) + const entries = useMemo(() => filterCases(search, family), [family, search]) + + useEffect(() => { + const handlePopState = () => setLocation(readCatalogLocation()) + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, []) + + useEffect(() => { + document.documentElement.dataset.theme = dark ? 'dark' : 'light' + }, [dark]) + + useEffect(() => { + const embed = route.view === 'embed' + document.body.classList.toggle('embed-mode', embed) + document.documentElement.classList.toggle('embed-mode', embed) + return () => { + document.body.classList.remove('embed-mode') + document.documentElement.classList.remove('embed-mode') + } + }, [route.view]) + + const navigate = (href: string) => { + window.history.pushState({}, '', href) + window.scrollTo({ top: 0 }) + setLocation(readCatalogLocation()) + } + const routeHref = (nextRoute: Exclude) => + withCatalogComparisonMode( + catalogRouteHref(nextRoute, basePath), + comparisonMode && nextRoute.view !== 'embed', + ) + const link = (nextRoute: Exclude) => ({ + href: routeHref(nextRoute), + navigate, + }) + const sharedControls = { + dark, + family, + search, + setDark, + setFamily, + setSearch, + } + if (route.view === 'index') { + return ( + + ) + } + + if (route.view === 'all') { + return ( + setRevision((value) => value + 1)} + /> + ) + } + + if (route.view === 'case') { + const entry = casesById.get(route.caseId) + if (!entry) return + return ( + setRevision((value) => value + 1)} + /> + ) + } + + if (route.view === 'embed') { + const entry = casesById.get(route.caseId) + return entry ? : + } + + return +} + +interface SharedControls { + dark: boolean + family: string + search: string + setDark: (value: boolean) => void + setFamily: (value: string) => void + setSearch: (value: string) => void +} + +type RouteLinkFactory = ( + route: Exclude, +) => Pick + +function CatalogIndex({ + comparisonMode, + controls, + entries, + link, +}: { + comparisonMode: boolean + controls: SharedControls + entries: ConformanceCaseMeta[] + link: RouteLinkFactory +}) { + useDocumentMeta( + 'TanStack Charts Catalog', + comparisonMode + ? 'Browse executable chart examples, conformance comparisons, source, and documentation-ready TanStack Charts proofs.' + : 'Browse executable TanStack Charts examples, source, and documentation-ready proofs.', + ) + + return ( + <> + +
+
+

Chart catalog

+

+ {comparisonMode + ? 'Browse typed examples, source, and reference comparisons.' + : 'Browse typed examples and their source.'} +

+
+
+ +
+
+

+ {entries.length} of {conformanceCases.length} cases +

+ + {comparisonMode ? 'Render every comparison' : 'Render every chart'} + +
+
+ {entries.length ? ( + entries.map((entry) => ( + + )) + ) : ( +

No catalog cases match those filters.

+ )} +
+
+ + ) +} + +function AllCasesPage({ + chartWidth, + comparisonMode, + controls, + entries, + link, + revision, + setChartWidth, + update, +}: { + chartWidth: number + comparisonMode: boolean + controls: SharedControls + entries: ConformanceCaseMeta[] + link: RouteLinkFactory + revision: number + setChartWidth: (value: number) => void + update: () => void +}) { + useDocumentMeta( + 'All charts · TanStack Charts Catalog', + comparisonMode + ? 'Render the complete TanStack Charts conformance catalog alongside its source-library references.' + : 'Render the complete TanStack Charts catalog with live source and controls.', + ) + + return ( + <> + +
+
+

All charts

+ {comparisonMode ? ( +

+ Each case uses the same inputs in both renderers. +

+ ) : null} +
+
+ + + + ) +} + +function CasePage({ + chartWidth, + comparisonMode, + dark, + entry, + link, + revision, + setChartWidth, + setDark, + update, +}: { + chartWidth: number + comparisonMode: boolean + dark: boolean + entry: ConformanceCaseMeta + link: RouteLinkFactory + revision: number + setChartWidth: (value: number) => void + setDark: (value: boolean) => void + update: () => void +}) { + const index = conformanceCases.indexOf(entry) + const previous = conformanceCases[index - 1] + const next = conformanceCases[index + 1] + useDocumentMeta( + `${entry.title} · TanStack Charts Catalog`, + comparisonMode + ? `${entry.intent} Compare the reference implementation with TanStack Charts, inspect source, or reference the chart in documentation.` + : `${entry.intent} Inspect the source or reference the chart in documentation.`, + ) + + return ( + <> + +
+
+ + ← Catalog + +

+ {String(entry.order).padStart(2, '0')} · {entry.family} +

+

{entry.title}

+

{entry.intent}

+
+ +
+ undefined, + setSearch: () => undefined, + }} + comparisonMode={comparisonMode} + includeFilters={false} + revision={revision} + setChartWidth={setChartWidth} + update={update} + /> + + + ) +} + +function ComparisonCases({ + chartWidth, + comparisonMode, + dark, + entries, + link, + revision, + detail = false, +}: { + chartWidth: number + comparisonMode: boolean + dark: boolean + entries: ConformanceCaseMeta[] + link: RouteLinkFactory + revision: number + detail?: boolean +}) { + return ( +
+ {entries.length ? ( + entries.map((entry) => ( + + )) + ) : ( +

No catalog cases match those filters.

+ )} +
+ ) +} + +function CatalogCard({ + comparisonMode, + entry, + link, +}: { + comparisonMode: boolean + entry: ConformanceCaseMeta + link: RouteLinkFactory +}) { + const reference = comparisonMode + ? getConformanceReferenceRenderer(entry) + : undefined + return ( +
+
+

+ {String(entry.order).padStart(2, '0')} · {entry.family} +

+ + {entry.support} + +
+

+ + {entry.title} + +

+

{entry.intent}

+ + +
+ ) +} + +function CaseCard({ + chartWidth, + comparisonMode, + dark, + entry, + link, + revision, +}: { + chartWidth: number + comparisonMode: boolean + dark: boolean + entry: ConformanceCaseMeta + link: RouteLinkFactory + revision: number +}) { + const referenceRenderer = getConformanceReferenceRenderer(entry) + const renderers = catalogRenderers(referenceRenderer, comparisonMode) + const docsDirective = `` + + return ( +
+
+
+

+ {String(entry.order).padStart(2, '0')} · {entry.family} +

+

+ + {entry.title} + +

+

{entry.intent}

+
+ + {entry.support} + +
+ +
+ {renderers.map((renderer) => ( + + ))} +
+ +
+ ) +} + +function RendererPanel({ + chartWidth, + entry, + renderer, + revision, +}: { + chartWidth: number + entry: ConformanceCaseMeta + renderer: ConformanceRenderer + revision: number +}) { + const containerRef = useRef(null) + const handleRef = useRef(null) + const inputRef = useRef({ + width: chartWidth, + height: chartHeight, + revision, + }) + const [metric, setMetric] = useState('pending') + const [source, setSource] = useState + > | null>(null) + const [state, setState] = useState<'pending' | 'ready' | 'gap' | 'error'>( + 'pending', + ) + const [error, setError] = useState('') + inputRef.current = { width: chartWidth, height: chartHeight, revision } + + useEffect(() => { + let active = true + let mountedHandle: ConformanceHandle | undefined + const mount = async () => { + setState('pending') + setMetric('pending') + setError('') + try { + await document.fonts?.ready + const [implementation, loadedSource] = + renderer === 'tanstack' + ? await Promise.all([ + loadTanStackImplementation(entry.id), + loadTanStackSources(entry.id), + ]) + : await loadComparisonRenderer(entry.id, renderer) + if (!active || !containerRef.current) return + setSource(loadedSource) + if (!implementation) { + setState('gap') + setMetric('gap') + return + } + + const container = containerRef.current + const startedAt = performance.now() + mountedHandle = implementation.mount(container, inputRef.current) + handleRef.current = mountedHandle + container.getBoundingClientRect() + const duration = performance.now() - startedAt + const elements = container.querySelectorAll('*').length + const svgBytes = [...container.querySelectorAll('svg')].reduce( + (total, svg) => + total + new TextEncoder().encode(svg.outerHTML).byteLength, + 0, + ) + setMetric( + `${duration.toFixed(2)} ms · ${elements} nodes · ${formatBytes(svgBytes)} SVG`, + ) + setState('ready') + } catch (reason) { + if (!active) return + const message = + reason instanceof Error ? reason.message : String(reason) + setError(`Renderer failed: ${message}`) + setMetric('error') + setState('error') + console.error(`Failed to render ${entry.id} with ${renderer}`, reason) + } + } + void mount() + return () => { + active = false + handleRef.current = null + mountedHandle?.destroy() + } + }, [entry.id, renderer]) + + useEffect(() => { + handleRef.current?.update(inputRef.current) + }, [chartWidth, revision]) + + return ( +
+
+

{rendererLabel(renderer)}

+ {metric} +
+ {state === 'gap' ? ( +
+

+ Not implemented. This is a recorded capability gap. +

+
+ ) : state === 'error' ? ( +
+

{error}

+
+ ) : ( +
+ )} +
+ Source +
+ {source ? : 'loading…'} +
+
+
+ ) +} + +function EmbedPage({ entry }: { entry: ConformanceCaseMeta }) { + const params = new URLSearchParams(window.location.search) + const height = parseChartEmbedHeight(params.get('height')) + const revision = parseChartEmbedRevision(params.get('revision')) + const preview = params.get('preview') === '1' + const parentOrigin = resolveChartEmbedParentOrigin(document.referrer) + const [theme, setTheme] = useState(() => + parseChartEmbedTheme(params.get('theme')), + ) + const [systemDark, setSystemDark] = useState( + () => window.matchMedia('(prefers-color-scheme: dark)').matches, + ) + const [error, setError] = useState('') + const containerRef = useRef(null) + useDocumentMeta(`${entry.title} · TanStack Charts`, entry.intent, true) + + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = () => setSystemDark(media.matches) + media.addEventListener('change', handleChange) + return () => media.removeEventListener('change', handleChange) + }, []) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const command = readTrustedChartEmbedThemeCommand( + event, + window.parent, + parentOrigin, + entry.id, + ) + if (command) setTheme(command.theme) + } + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [entry.id, parentOrigin]) + + useEffect(() => { + const dark = theme === 'dark' || (theme === 'system' && systemDark) + document.documentElement.dataset.theme = dark ? 'dark' : 'light' + }, [systemDark, theme]) + + useEffect(() => { + let active = true + let handle: ConformanceHandle | undefined + let observer: ResizeObserver | undefined + let frame = 0 + const mount = async () => { + try { + await document.fonts?.ready + const container = containerRef.current + if (!active || !container) return + const implementation = await loadTanStackImplementation(entry.id) + if (!active || !container) return + if (!implementation) { + throw new Error( + 'This catalog case has no TanStack implementation yet.', + ) + } + let width = measureEmbedWidth(container) + const mountImplementation = + preview && implementation.catalogCase + ? implementation.catalogCase.mount + : implementation.mount + handle = mountImplementation(container, { + width, + height, + revision, + interactive: !preview, + preview, + }) + observer = new ResizeObserver(() => { + const nextWidth = measureEmbedWidth(container) + if (nextWidth === width || nextWidth < 1) return + width = nextWidth + handle?.update({ + width, + height, + revision, + interactive: !preview, + preview, + }) + postEmbedMessage('resize', entry, height, parentOrigin) + }) + observer.observe(container) + frame = requestAnimationFrame(() => { + postEmbedMessage('ready', entry, height, parentOrigin) + }) + } catch (reason) { + if (!active) return + const message = + reason instanceof Error ? reason.message : String(reason) + setError(`Renderer failed: ${message}`) + postEmbedMessage('error', entry, height, parentOrigin) + } + } + void mount() + return () => { + active = false + cancelAnimationFrame(frame) + observer?.disconnect() + handle?.destroy() + } + }, [entry, height, parentOrigin, preview, revision]) + + return ( +
+ {error ? ( +
+

{error}

+
+ ) : ( +
+ )} +
+ ) +} + +function SiteHeader({ + active, + link, +}: { + active?: 'catalog' | 'all' + link: RouteLinkFactory +}) { + return ( +
+
+ + + TanStack + + + + Charts Catalog + +
+ +
+ ) +} + +function BrowseToolbar({ controls }: { controls: SharedControls }) { + return ( +
+ + +
+ ) +} + +function ComparisonToolbar({ + chartWidth, + comparisonMode, + controls, + includeFilters = true, + revision, + setChartWidth, + update, +}: { + chartWidth: number + comparisonMode: boolean + controls: SharedControls + includeFilters?: boolean + revision: number + setChartWidth: (value: number) => void + update: () => void +}) { + return ( +
+ {includeFilters ? : null} + + + +
+ ) +} + +function ToolbarFilters({ controls }: { controls: SharedControls }) { + return ( + <> + + + + ) +} + +function ThemeButton({ controls }: { controls: SharedControls }) { + return ( + + ) +} + +function FeatureList({ entry }: { entry: ConformanceCaseMeta }) { + return ( +
    + {entry.features.map((feature) => ( +
  • {feature}
  • + ))} +
+ ) +} + +interface CatalogLinkProps extends AnchorHTMLAttributes { + href: string + navigate: (href: string) => void + children: ReactNode +} + +function CatalogLink({ + children, + href, + navigate, + onClick, + ...props +}: CatalogLinkProps) { + const handleClick = (event: ReactMouseEvent) => { + onClick?.(event) + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + props.target === '_blank' || + props.download + ) { + return + } + const url = new URL(href, window.location.href) + if (url.origin !== window.location.origin) return + event.preventDefault() + navigate(url.href) + } + return ( + + {children} + + ) +} + +function NotFound({ + embed = false, + link, +}: { + embed?: boolean + link: RouteLinkFactory +}) { + useDocumentMeta( + 'Chart not found · TanStack Charts Catalog', + 'Chart not found.', + true, + ) + return ( + <> + {embed ? null : } +
+

Chart not found.

+ {embed ? null : ( + + Browse the catalog + + )} +
+ + ) +} + +function filterCases(search: string, family: string): ConformanceCaseMeta[] { + return conformanceCases.filter((entry) => { + if (family !== 'all' && entry.family !== family) return false + if (!search) return true + const text = [entry.title, entry.family, entry.intent, ...entry.features] + .join(' ') + .toLowerCase() + return text.includes(search) + }) +} + +function readCatalogLocation() { + return { + pathname: window.location.pathname, + search: window.location.search, + } +} + +async function loadComparisonRenderer( + id: string, + renderer: ConformanceReferenceRenderer, +) { + comparisonCatalogPromise ??= + import('../../../benchmarks/conformance/comparison-catalog') + const catalog = await comparisonCatalogPromise + return Promise.all([ + catalog.loadComparisonImplementation(id, renderer), + catalog.loadComparisonSources(id, renderer), + ]) +} + +function rendererLabel(renderer: ConformanceRenderer): string { + if (renderer === 'observable-plot') return 'Observable Plot' + if (renderer === 'recharts') return 'Recharts' + if (renderer === 'echarts') return 'Apache ECharts' + return 'TanStack Charts' +} + +function useDocumentMeta(title: string, description: string, noIndex = false) { + useEffect(() => { + document.title = title + setMetaContent('description', description) + setMetaContent('robots', noIndex ? 'noindex,follow' : 'index,follow') + setPropertyContent('og:title', title) + setPropertyContent('og:description', description) + setPropertyContent('og:url', canonicalUrl()) + const canonical = document.querySelector( + 'link[rel="canonical"]', + ) + if (canonical) canonical.href = canonicalUrl() + }, [description, noIndex, title]) +} + +function setMetaContent(name: string, content: string) { + document + .querySelector(`meta[name="${name}"]`) + ?.setAttribute('content', content) +} + +function setPropertyContent(property: string, content: string) { + document + .querySelector(`meta[property="${property}"]`) + ?.setAttribute('content', content) +} + +function canonicalUrl(): string { + return `${window.location.origin}${window.location.pathname}` +} + +function postEmbedMessage( + status: ChartEmbedStatus, + entry: ConformanceCaseMeta, + height: number, + parentOrigin: string | null, +) { + if (window.parent === window || !parentOrigin) return + window.parent.postMessage( + createChartEmbedStatusMessage(status, entry.id, height), + parentOrigin, + ) +} + +function measureEmbedWidth(container: HTMLElement): number { + return Math.max(1, Math.floor(container.getBoundingClientRect().width)) +} + +function formatBytes(bytes: number): string { + return bytes < 1_024 ? `${bytes} B` : `${(bytes / 1_024).toFixed(1)} kB` +} + +const app = document.querySelector('#app') +if (!app) throw new Error('Missing conformance app root') +createRoot(app).render() diff --git a/examples/conformance/src/source-view.test.ts b/examples/conformance/src/source-view.test.ts index af488ae3..3ae207aa 100644 --- a/examples/conformance/src/source-view.test.ts +++ b/examples/conformance/src/source-view.test.ts @@ -42,10 +42,10 @@ describe('renderCatalogSourceView', () => { expect(html.indexOf('tanstack.ts')).toBeLessThan(html.indexOf('model.ts')) expect(html.indexOf('model.ts')).toBeLessThan(html.indexOf('data.ts')) expect(html).toMatch( - /
[\s\S]*tanstack\.ts/, + /
[\s\S]*tanstack\.ts/, ) expect(html).toMatch( - /
[\s\S]*model\.ts/, + /
[\s\S]*model\.ts/, ) expect(html).toMatch( /
[\s\S]*data\.ts/, diff --git a/examples/conformance/src/source-view.ts b/examples/conformance/src/source-view.ts deleted file mode 100644 index 94721d50..00000000 --- a/examples/conformance/src/source-view.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { - CatalogSourceClosure, - CatalogSourceFile, - CatalogSourceKind, -} from '../../../benchmarks/conformance/catalog-loader' -import type { DemoDatasetMetadata } from '@charts-poc/demo-data/metadata' - -const sourceKindOrder: Record = { - entry: 0, - support: 1, - fixture: 2, -} - -export function renderCatalogSourceView(closure: CatalogSourceClosure): string { - if (!closure.files.length) { - return '

No implementation yet.

' - } - - const files = [...closure.files].sort(compareSourceFiles) - const chartLines = closure.roles.entry.lines + closure.roles.support.lines - const fixtureLines = closure.roles.fixture.lines - const excludedHarness = - closure.excludedHarnessPaths.length > 0 - ? `Benchmark harness excluded · ${closure.excludedHarnessPaths - .map(escapeHtml) - .join(', ')}` - : '' - - return ` -

- ${[ - formatCount(chartLines, 'chart line'), - fixtureLines ? formatCount(fixtureLines, 'data-selection line') : '', - formatCount(closure.totalFiles, 'file'), - ] - .filter(Boolean) - .join(' · ')} - ${excludedHarness} -

- ${closure.datasets.map(renderDataset).join('')} - ${files.map(renderSourceFile).join('')} - ` -} - -function renderDataset(dataset: DemoDatasetMetadata): string { - const schema = dataset.schema - .map( - ({ name, types }) => - `${escapeHtml(name)} ${escapeHtml(types.join(' | '))}`, - ) - .join(', ') - - return ` -
-
- ${escapeHtml(dataset.title)} - ${formatCount(dataset.records, 'record')} · ${escapeHtml(dataset.format)} · ${formatBytes(dataset.bytes)} -
-

${schema}

-

- ${escapeHtml(dataset.source)} - ${escapeHtml(dataset.observablePackage)} · ${escapeHtml(dataset.license)} · SHA-256 ${escapeHtml(dataset.sha256.slice(0, 12))} - Pinned snapshot -

-
- ` -} - -function renderSourceFile(file: CatalogSourceFile): string { - const open = file.kind === 'fixture' ? '' : ' open' - return ` -
- - ${escapeHtml(file.path)} - ${formatCount(file.lines, 'line')} · ${ - file.kind - } - -
${escapeHtml(file.source)}
-
- ` -} - -function compareSourceFiles( - left: CatalogSourceFile, - right: CatalogSourceFile, -): number { - return ( - sourceKindOrder[left.kind] - sourceKindOrder[right.kind] || - left.path.localeCompare(right.path) - ) -} - -function formatCount(value: number, noun: string): string { - return `${value.toLocaleString('en-US')} ${noun}${value === 1 ? '' : 's'}` -} - -function formatBytes(value: number): string { - if (value < 1_000) return `${value} B` - if (value < 1_000_000) return `${(value / 1_000).toFixed(1)} kB` - return `${(value / 1_000_000).toFixed(1)} MB` -} - -function escapeHtml(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} diff --git a/examples/conformance/src/source-view.tsx b/examples/conformance/src/source-view.tsx new file mode 100644 index 00000000..1f6a7cf1 --- /dev/null +++ b/examples/conformance/src/source-view.tsx @@ -0,0 +1,134 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { + CatalogSourceClosure, + CatalogSourceFile, + CatalogSourceKind, +} from '../../../benchmarks/conformance/catalog-loader' +import type { DemoDatasetMetadata } from '@charts-poc/demo-data/metadata' + +const sourceKindOrder: Record = { + entry: 0, + support: 1, + fixture: 2, +} + +export function CatalogSourceView({ + closure, +}: { + closure: CatalogSourceClosure +}) { + if (!closure.files.length) { + return

No implementation yet.

+ } + + const files = [...closure.files].sort(compareSourceFiles) + const chartLines = closure.roles.entry.lines + closure.roles.support.lines + const fixtureLines = closure.roles.fixture.lines + + return ( + <> +

+ + {[ + formatCount(chartLines, 'chart line'), + fixtureLines + ? formatCount(fixtureLines, 'data-selection line') + : '', + formatCount(closure.totalFiles, 'file'), + ] + .filter(Boolean) + .join(' · ')} + + {closure.excludedHarnessPaths.length > 0 ? ( + + Benchmark harness excluded ·{' '} + {closure.excludedHarnessPaths.join(', ')} + + ) : null} +

+ {closure.datasets.map((dataset) => ( + + ))} + {files.map((file) => ( + + ))} + + ) +} + +export function renderCatalogSourceView(closure: CatalogSourceClosure): string { + return renderToStaticMarkup() +} + +function SourceDataset({ dataset }: { dataset: DemoDatasetMetadata }) { + return ( +
+
+ {dataset.title} + + {formatCount(dataset.records, 'record')} · {dataset.format} ·{' '} + {formatBytes(dataset.bytes)} + +
+

+ {dataset.schema.map(({ name, types }, index) => ( + + {index ? ', ' : null} + {name} {types.join(' | ')} + + ))} +

+

+ + {dataset.source} + + + {dataset.observablePackage} · {dataset.license} · SHA-256{' '} + {dataset.sha256.slice(0, 12)} + + + Pinned snapshot + +

+
+ ) +} + +function SourceFile({ file }: { file: CatalogSourceFile }) { + return ( +
+ + {file.path} + + {formatCount(file.lines, 'line')} · {file.kind} + + +
+        {file.source}
+      
+
+ ) +} + +function compareSourceFiles( + left: CatalogSourceFile, + right: CatalogSourceFile, +): number { + return ( + sourceKindOrder[left.kind] - sourceKindOrder[right.kind] || + left.path.localeCompare(right.path) + ) +} + +function formatCount(value: number, noun: string): string { + return `${value.toLocaleString('en-US')} ${noun}${value === 1 ? '' : 's'}` +} + +function formatBytes(value: number): string { + if (value < 1_000) return `${value} B` + if (value < 1_000_000) return `${(value / 1_000).toFixed(1)} kB` + return `${(value / 1_000_000).toFixed(1)} MB` +}