TanStack
Catalog

Interrupted spring updates

motion

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