TanStack
Catalog

Definition-owned motion

motion

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