TanStack
Catalog

Spring line updates

motion

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]
  )
}