485 lines · 3 files · 13.0 kB
cases/113-motion-updates/controls.tsx96 lines · dependency
cases/113-motion-updates/controls.tsx
import { forwardRef, type ReactNode } from 'react'
export const ControlBar = forwardRef<
HTMLDivElement,
{ label: string; children: ReactNode }
>(function ControlBar({ label, children }, ref) {
return (
<div
ref={ref}
role="group"
aria-label={label}
style={{
display: 'flex',
alignItems: 'center',
alignContent: 'center',
flexWrap: 'wrap',
gap: '8px 12px',
padding: '8px 10px',
font: '500 12px/1.2 system-ui, sans-serif',
}}
>
{children}
</div>
)
})
export function ControlField({
children,
label,
}: {
children: ReactNode
label: string
}) {
return (
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
}}
>
{label}
{children}
</label>
)
}
export const ControlButton = forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(function ControlButton({ children, style, type = 'button', ...props }, ref) {
return (
<button
{...props}
ref={ref}
type={type}
style={{ padding: '0 14px', ...style }}
>
{children}
</button>
)
})
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 (
<ControlField label={label}>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.currentTarget.value))}
style={{ width: 96 }}
/>
<output>{`${value}${suffix}`}</output>
</ControlField>
)
}cases/113-motion-updates/example.tsx343 lines · entry
cases/113-motion-updates/example.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
import { motion } from '@tanstack/charts/motion'
import { Chart } from '@tanstack/charts/react/core'
import { ControlBar, ControlButton, ControlField, RangeField } from './controls'
import { updateStages as stages } from './model'
import type { UpdateRow } from './model'
import { barY, defineChart, lineY } from '@tanstack/charts'
import { scaleBand, scaleLinear } from 'd3-scale'
import type { ChartMotionTweenTransition } from '@tanstack/charts/motion'
const initialSettings: UpdateSettings = {
duration: 1_100,
easing: undefined,
spring: false,
stiffness: 170,
damping: 14,
mass: 1,
}
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
export interface UpdateSettings {
duration: number
easing: ChartMotionTweenTransition['easing']
spring: boolean
stiffness: number
damping: number
mass: number
}
export function motionUpdatesDefinition(
rows: readonly UpdateRow[],
settings: UpdateSettings,
) {
return defineChart({
motion: {
transition: settings.spring
? {
type: 'spring',
stiffness: settings.stiffness,
damping: settings.damping,
mass: settings.mass,
}
: {
type: 'tween',
duration: settings.duration,
easing: settings.easing,
},
},
marks: [
barY(rows, {
id: 'actual',
x: 'period',
y: 'actual',
key: 'id',
fill: '#7c3aed',
radius: 7,
inset: 4,
motion(context) {
if (settings.spring) {
if (context.phase === 'exit') {
return {
transition: {
type: 'spring',
stiffness: settings.stiffness * 1.25,
damping: settings.damping * 1.15,
},
}
}
if (context.datum?.featured) {
return {
delay: context.phase === 'enter' ? 70 : 0,
transition: {
type: 'spring',
mass: settings.mass * 1.35,
},
}
}
return undefined
}
if (context.phase === 'exit') {
return {
transition: {
type: 'tween',
duration: settings.duration * 0.45,
},
}
}
if (context.datum?.featured) {
return {
delay: settings.duration * 0.16,
transition: {
type: 'tween',
duration: settings.duration * 0.6,
},
}
}
return undefined
},
}),
lineY(rows, {
id: 'target',
x: 'period',
y: 'target',
key: 'id',
stroke: '#f97316',
strokeWidth: 3,
motion: {
delay: 80,
transition: settings.spring
? {
type: 'spring',
stiffness: settings.stiffness * 0.78,
}
: {
type: 'tween',
duration: settings.duration * 0.82,
},
},
}),
],
scales: {
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 },
maxFocusDistance: 28,
})
}
export function readEasing(value: string): UpdateSettings['easing'] {
return value === 'linear' ||
value === 'ease' ||
value === 'ease-in' ||
value === 'ease-out' ||
value === 'ease-in-out'
? value
: undefined
}
export function springRegime(settings: UpdateSettings) {
const ratio =
settings.damping / (2 * Math.sqrt(settings.stiffness * settings.mass))
if (ratio < 0.99) return 'underdamped'
if (ratio > 1.01) return 'overdamped'
return 'critical'
}
export default function MotionUpdatesExample({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '113-motion-updates'
const viewRef = useRef<HTMLDivElement>(null)
const updateRef = useRef<HTMLButtonElement>(null)
const interruptRef = useRef<HTMLButtonElement>(null)
const replayRef = useRef<HTMLButtonElement>(null)
const timerRef = useRef<number>(undefined)
const [stage, setStage] = useState(
() => Math.abs(input.revision) % stages.length,
)
const [settings, setSettings] = useState(initialSettings)
const [replayCount, setReplayCount] = useState(1)
const [, 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<UpdateSettings>) => {
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(), [])
return (
<div
ref={viewRef}
data-conformance-view="main"
style={{
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
width: input.width,
height: input.height,
color: 'CanvasText',
}}
>
<ControlBar label="Keyed update motion controls">
{settings.spring ? null : (
<RangeField
label="Duration"
min={300}
max={1_800}
step={100}
suffix=" ms"
value={settings.duration}
onChange={(duration) => rebuild({ duration })}
/>
)}
<ControlField label="Transition">
<select
value={
settings.spring
? 'spring'
: typeof settings.easing === 'string'
? settings.easing
: 'polished'
}
onChange={(event) => {
const value = event.currentTarget.value
rebuild({
spring: value === 'spring',
easing: readEasing(value),
})
}}
>
<option value="polished">Tween · Polished</option>
<option value="spring">Spring</option>
<option value="ease">Tween · Ease</option>
<option value="ease-out">Tween · Ease out</option>
<option value="ease-in-out">Tween · Ease in/out</option>
<option value="linear">Tween · Linear</option>
</select>
</ControlField>
{settings.spring ? (
<>
<RangeField
label="Stiffness"
min={40}
max={400}
step={10}
value={settings.stiffness}
onChange={(stiffness) => rebuild({ stiffness })}
/>
<RangeField
label="Damping"
min={0}
max={50}
step={1}
value={settings.damping}
onChange={(damping) => rebuild({ damping })}
/>
<RangeField
label="Mass"
min={0.25}
max={3}
step={0.25}
suffix="×"
value={settings.mass}
onChange={(mass) => rebuild({ mass })}
/>
<output style={{ opacity: 0.7 }}>
{springRegime(settings)} · momentum preserved
</output>
</>
) : null}
<ControlButton ref={updateRef} onClick={advance}>
Update
</ControlButton>
<ControlButton ref={interruptRef} onClick={interrupt}>
Interrupt
</ControlButton>
<ControlButton ref={replayRef} onClick={replay}>
Replay
</ControlButton>
<output aria-live="polite" style={{ opacity: 0.7 }}>
{announcement || `Stage ${stage + 1} of ${stages.length}`}
</output>
</ControlBar>
<Chart
key={replayCount}
idPrefix={idPrefix}
definition={definition}
renderer={renderer}
width={input.width}
height={Math.max(180, input.height - (settings.spring ? 96 : 58))}
ariaLabel="Keyed actuals and targets during interrupted updates"
style={{ minHeight: 0 }}
/>
</div>
)
}cases/113-motion-updates/model.ts46 lines · dependency
cases/113-motion-updates/model.ts
export interface UpdateRow {
id: string
period: string
actual: number
target: number
featured?: boolean
}
export const updateStages: readonly (readonly UpdateRow[])[] = [
[
{ id: 'jan', period: 'Jan', actual: 28, target: 32 },
{ id: 'feb', period: 'Feb', actual: 36, target: 35 },
{ id: 'mar', period: 'Mar', actual: 45, target: 43 },
{ id: 'apr', period: 'Apr', actual: 57, target: 50, featured: true },
{ id: 'may', period: 'May', actual: 51, target: 56 },
{ id: 'jun', period: 'Jun', actual: 68, target: 63 },
{ id: 'jul', period: 'Jul', actual: 75, target: 71 },
{ id: 'aug', period: 'Aug', actual: 83, target: 79 },
],
[
{ id: 'aug', period: 'Aug', actual: 64, target: 72 },
{ id: 'apr', period: 'Apr', actual: 86, target: 68, featured: true },
{ id: 'jan', period: 'Jan', actual: 48, target: 42 },
{ id: 'jul', period: 'Jul', actual: 58, target: 65 },
{ id: 'mar', period: 'Mar', actual: 72, target: 55 },
{ id: 'sep', period: 'Sep', actual: 39, target: 76 },
{ id: 'jun', period: 'Jun', actual: 78, target: 61 },
{ id: 'may', period: 'May', actual: 62, target: 59 },
],
[
{ id: 'may', period: 'May', actual: 88, target: 70 },
{ id: 'oct', period: 'Oct', actual: 54, target: 82 },
{ id: 'mar', period: 'Mar', actual: 33, target: 48 },
{ id: 'aug', period: 'Aug', actual: 91, target: 77 },
{ id: 'apr', period: 'Apr', actual: 43, target: 63, featured: true },
{ id: 'sep', period: 'Sep', actual: 74, target: 80 },
{ id: 'jan', period: 'Jan', actual: 66, target: 52 },
{ id: 'jul', period: 'Jul', actual: 49, target: 68 },
],
]
export function updateRows(revision: number) {
return (
updateStages[Math.abs(revision) % updateStages.length] ?? updateStages[0]
)
}