338 lines · 3 files · 8.9 kB
cases/114-spring-line-motion/controls.tsx96 lines · dependency
cases/114-spring-line-motion/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/114-spring-line-motion/example.tsx199 lines · entry
cases/114-spring-line-motion/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 } from './controls'
import { springLineStages } from './model'
import type { SpringLineRow } from './model'
import { defineChart, lineY } from '@tanstack/charts'
import { scaleBand, scaleLinear } from 'd3-scale'
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
export type SpringLineTransitionMode = 'spring' | 'tween'
export function springLineMotionDefinition(
rows: readonly SpringLineRow[],
mode: SpringLineTransitionMode,
) {
return defineChart({
motion: {
transition:
mode === 'spring'
? { type: 'spring', stiffness: 170, damping: 18, mass: 1 }
: { type: 'tween', duration: 650, easing: 'ease-out' },
},
marks: [
lineY(rows, {
id: 'primary',
x: 'period',
y: 'primary',
key: 'id',
stroke: '#7c3aed',
strokeWidth: 4,
}),
lineY(rows, {
id: 'comparison',
x: 'period',
y: 'comparison',
key: 'id',
stroke: '#f97316',
strokeWidth: 3,
motion(context) {
return {
delay: context.phase === 'enter' ? 90 : 0,
transition:
mode === 'spring'
? { type: 'spring', mass: 1.2 }
: {
type: 'tween',
duration: 820,
easing: 'ease-in-out',
},
}
},
}),
],
scales: {
x: { scale: scaleBand().domain(rows.map((row) => row.period)) },
y: { scale: scaleLinear().domain([0, 100]) },
},
guides: false,
margin: { top: 24, right: 24, bottom: 24, left: 24 },
})
}
export default function SpringLineMotionExample({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '114-spring-line-motion'
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) % springLineStages.length,
)
const [mode, setMode] = useState<SpringLineTransitionMode>('spring')
const [replayCount, setReplayCount] = useState(1)
const [, 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(), [])
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="Line motion controls">
<ControlField label="Transition">
<select
value={mode}
onChange={(event) => {
setMode(
event.currentTarget.value === 'tween' ? 'tween' : 'spring',
)
replay()
}}
>
<option value="spring">Spring</option>
<option value="tween">Tween</option>
</select>
</ControlField>
<ControlButton ref={updateRef} onClick={update}>
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 ${springLineStages.length}`}
</output>
</ControlBar>
<Chart
key={replayCount}
idPrefix={idPrefix}
definition={definition}
renderer={renderer}
width={input.width}
height={Math.max(180, input.height - 58)}
ariaLabel="Primary and comparison series with spring motion"
style={{ minHeight: 0 }}
/>
</div>
)
}cases/114-spring-line-motion/model.ts43 lines · dependency
cases/114-spring-line-motion/model.ts
export interface SpringLineRow {
id: string
period: string
primary: number
comparison: number
}
export const springLineStages: readonly (readonly SpringLineRow[])[] = [
[
{ id: 'jan', period: 'Jan', primary: 24, comparison: 34 },
{ id: 'feb', period: 'Feb', primary: 38, comparison: 40 },
{ id: 'mar', period: 'Mar', primary: 31, comparison: 46 },
{ id: 'apr', period: 'Apr', primary: 52, comparison: 49 },
{ id: 'may', period: 'May', primary: 47, comparison: 57 },
{ id: 'jun', period: 'Jun', primary: 66, comparison: 62 },
{ id: 'jul', period: 'Jul', primary: 61, comparison: 70 },
],
[
{ id: 'jan', period: 'Jan', primary: 62, comparison: 45 },
{ id: 'feb', period: 'Feb', primary: 48, comparison: 58 },
{ id: 'mar', period: 'Mar', primary: 74, comparison: 52 },
{ id: 'apr', period: 'Apr', primary: 43, comparison: 67 },
{ id: 'may', period: 'May', primary: 81, comparison: 61 },
{ id: 'jun', period: 'Jun', primary: 55, comparison: 76 },
{ id: 'jul', period: 'Jul', primary: 84, comparison: 69 },
],
[
{ id: 'jan', period: 'Jan', primary: 33, comparison: 71 },
{ id: 'feb', period: 'Feb', primary: 76, comparison: 51 },
{ id: 'mar', period: 'Mar', primary: 42, comparison: 78 },
{ id: 'apr', period: 'Apr', primary: 82, comparison: 56 },
{ id: 'may', period: 'May', primary: 39, comparison: 80 },
{ id: 'jun', period: 'Jun', primary: 73, comparison: 59 },
{ id: 'jul', period: 'Jul', primary: 49, comparison: 74 },
],
]
export function springLineRows(revision: number) {
return (
springLineStages[Math.abs(revision) % springLineStages.length] ??
springLineStages[0]
)
}