354 lines · 3 files · 9.1 kB
cases/115-definition-motion/controls.tsx96 lines · dependency
cases/115-definition-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/115-definition-motion/example.tsx216 lines · entry
cases/115-definition-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 } from './controls'
import { definitionMotionStages } from './model'
import type { DefinitionMotionRow } from './model'
import { barY, defineChart, lineY } from '@tanstack/charts'
import { scaleBand, scaleLinear } from 'd3-scale'
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
export function definitionMotionDefinition(
rows: readonly DefinitionMotionRow[],
preview = false,
) {
const maximum = Math.max(100, ...rows.map((row) => row.actual))
const yMaximum = Math.ceil(maximum / 20) * 20
const guideMotion = {
transition: {
type: 'tween' as const,
duration: 260,
easing: 'ease-out' as const,
},
}
return defineChart({
motion: {
transition: { type: 'spring', stiffness: 170, damping: 18, mass: 1 },
},
marks: [
barY(rows, {
id: 'actual',
x: 'period',
y: 'actual',
key: 'id',
fill: '#7c3aed',
radius: 6,
inset: 5,
motion(context) {
return {
delay: context.phase === 'enter' ? context.datumIndex * 34 : 0,
transition: context.datum?.featured
? { type: 'spring', mass: 1.45 }
: undefined,
}
},
}),
lineY(rows, {
id: 'target',
x: 'period',
y: 'target',
key: 'id',
stroke: '#f97316',
strokeWidth: 3,
motion: {
transition: {
type: 'tween',
duration: 520,
easing: 'ease-in-out',
},
},
}),
],
scales: {
x: {
scale: scaleBand().domain(rows.map((row) => row.period)),
axis: {
motion: guideMotion,
ticks: { motion: guideMotion },
tickLabels: {
motion(context) {
return {
delay: context.datumIndex * 18,
transition: { type: 'tween', duration: 220 },
}
},
},
label: { text: 'Period', motion: guideMotion },
},
},
y: {
scale: scaleLinear().domain([0, yMaximum]),
grid: true,
axis: {
motion: guideMotion,
ticks: { motion: guideMotion },
tickLabels: { motion: guideMotion },
label: { text: 'Value', motion: guideMotion },
},
},
},
margin: preview
? { top: 12, right: 4, bottom: 40, left: 46 }
: { top: 20, right: 24 },
maxFocusDistance: 32,
})
}
export default function DefinitionMotionExample({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '115-definition-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) % definitionMotionStages.length,
)
const [replayCount, setReplayCount] = useState(1)
const [, 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(), [])
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="Definition motion controls">
<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 ${definitionMotionStages.length}`}
</output>
</ControlBar>
<Chart
key={replayCount}
idPrefix={idPrefix}
definition={definition}
renderer={renderer}
width={input.width}
height={Math.max(220, input.height - 58)}
ariaLabel="Definition-owned chart, mark, datum, and guide motion"
style={{ minHeight: 0 }}
/>
</div>
)
}cases/115-definition-motion/model.ts42 lines · dependency
cases/115-definition-motion/model.ts
export interface DefinitionMotionRow {
id: string
period: string
actual: number
target: number
featured?: boolean
}
export const definitionMotionStages: readonly (readonly DefinitionMotionRow[])[] =
[
[
{ id: 'jan', period: 'Jan', actual: 34, target: 40 },
{ id: 'feb', period: 'Feb', actual: 52, target: 48 },
{ id: 'mar', period: 'Mar', actual: 46, target: 55 },
{ id: 'apr', period: 'Apr', actual: 71, target: 62, featured: true },
{ id: 'may', period: 'May', actual: 64, target: 68 },
{ id: 'jun', period: 'Jun', actual: 82, target: 76 },
],
[
{ id: 'mar', period: 'Mar', actual: 77, target: 64 },
{ id: 'jan', period: 'Jan', actual: 58, target: 52 },
{ id: 'apr', period: 'Apr', actual: 43, target: 70, featured: true },
{ id: 'jul', period: 'Jul', actual: 96, target: 84 },
{ id: 'may', period: 'May', actual: 86, target: 73 },
{ id: 'aug', period: 'Aug', actual: 112, target: 91 },
],
[
{ id: 'aug', period: 'Aug', actual: 61, target: 82 },
{ id: 'apr', period: 'Apr', actual: 103, target: 76, featured: true },
{ id: 'jan', period: 'Jan', actual: 42, target: 59 },
{ id: 'sep', period: 'Sep', actual: 88, target: 94 },
{ id: 'jul', period: 'Jul', actual: 73, target: 86 },
],
]
export function definitionMotionRows(revision: number) {
return (
definitionMotionStages[
Math.abs(revision) % definitionMotionStages.length
] ?? definitionMotionStages[0]
)
}