TanStack
Catalog

Cross-chart geometry morph

motion

548 lines · 3 files · 14.5 kB

cases/116-geometry-morph/controls.tsx96 lines · dependency
cases/116-geometry-morph/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/116-geometry-morph/example.tsx428 lines · entry
cases/116-geometry-morph/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 { morphData, morphModes } from './model'
import type { MorphDatum, MorphMode } from './model'
import { createMark, defineChart } from '@tanstack/charts'
import { scaleLinear } from 'd3-scale'
import type {
  ChartMotionDefinition,
  ChartPoint,
  SceneNode,
} from '@tanstack/charts'
export interface ExampleProps {
  width?: number
  height?: number
  revision?: number
}

type Point = readonly [number, number]

const sampleCount = 48

export function geometryMorphDefinition(
  data: readonly MorphDatum[],
  mode: MorphMode,
) {
  return defineChart({
    motion: {
      transition: {
        type: 'spring',
        stiffness: 105,
        damping: 16,
        mass: 0.9,
      },
    },
    marks: [
      normalizedTopologyMark(data, mode, {
        id: 'geometry-morph',
        key: (datum) => datum.id,
        fill: (datum) => datum.color,
        fillOpacity: 0.9,
        stroke: 'Canvas',
        strokeOpacity: 0.75,
        strokeWidth: 1.5,
        lineJoin: 'round',
        motion(context) {
          return {
            delay: context.phase === 'enter' ? context.datumIndex * 38 : 0,
            transition:
              context.datum?.id === 'violet'
                ? { type: 'spring', mass: 1.35 }
                : undefined,
          }
        },
      }),
    ],
    scales: {
      x: { scale: scaleLinear().domain([0, data.length - 1]) },
      y: { scale: scaleLinear().domain([0, 100]) },
    },

    guides: false,
    margin: 0,
  })
}

interface NormalizedTopologyMarkOptions {
  id: string
  key: (datum: MorphDatum, index: number) => string
  fill: (datum: MorphDatum, index: number) => string
  fillOpacity: number
  stroke: string
  strokeOpacity: number
  strokeWidth: number
  lineJoin: 'miter' | 'round' | 'bevel'
  motion?: ChartMotionDefinition<MorphDatum>
}

function normalizedTopologyMark(
  data: readonly MorphDatum[],
  mode: MorphMode,
  options: NormalizedTopologyMarkOptions,
) {
  return createMark<MorphDatum, number, number>(() => {
    const id = options.id
    return {
      id,
      channels: {
        x: { scale: 'x', values: data.map((_datum, index) => index) },
        y: {
          scale: 'y',
          values: data.map((datum) => datum.value),
          includeZero: true,
        },
      },
      render: ({ chart }) => {
        const geometries = geometryForMode(data, mode, chart)
        const nodes: SceneNode[] = []
        const points: ChartPoint<MorphDatum, number, number>[] = []
        data.forEach((datum, datumIndex) => {
          const geometry = geometries[datumIndex]
          if (!geometry) return
          const datumKey = options.key(datum, datumIndex)
          const key = `${id}:${datumKey}`
          nodes.push({
            kind: 'area',
            key,
            points: geometry.points,
            style: {
              fill: options.fill(datum, datumIndex),
              fillOpacity: options.fillOpacity,
              stroke: options.stroke,
              strokeOpacity: options.strokeOpacity,
              strokeWidth: options.strokeWidth,
              lineJoin: options.lineJoin,
            },
          })
          points.push({
            key,
            markId: id,
            group: datumKey,
            groupLabel: datum.label,
            datum,
            datumIndex,
            xValue: datumIndex,
            yValue: datum.value,
            x: geometry.center[0],
            y: geometry.center[1],
            color: datum.color,
          })
        })
        return {
          nodes: [
            {
              kind: 'group',
              key: id,
              className: 'ts-chart__area ts-chart__geometry-morph',
              children: nodes,
            },
          ],
          points,
        }
      },
    }
  }, options.motion)
}

function geometryForMode(
  data: readonly MorphDatum[],
  mode: MorphMode,
  chart: { x: number; y: number; width: number; height: number },
) {
  const padding = Math.max(14, Math.min(chart.width, chart.height) * 0.05)
  const center: Point = [chart.x + chart.width / 2, chart.y + chart.height / 2]
  const radius = Math.max(12, Math.min(chart.width, chart.height) / 2 - padding)
  const maximum = Math.max(...data.map((datum) => datum.value))

  if (mode === 'bars') {
    const gap = Math.max(4, chart.width * 0.012)
    const width = (chart.width - padding * 2) / data.length
    return data.map((datum, index) => {
      const height = ((chart.height - padding * 2) * datum.value) / 100
      const x = chart.x + padding + index * width + gap / 2
      const y = chart.y + chart.height - padding - height
      const barWidth = width - gap
      return {
        points: sampleRectangle(x, y, barWidth, height, sampleCount),
        center: [x + barWidth / 2, y + height / 2] as Point,
      }
    })
  }

  if (mode === 'bubbles') {
    const columns = 3
    const rows = 2
    const cellWidth = (chart.width - padding * 2) / columns
    const cellHeight = (chart.height - padding * 2) / rows
    return data.map((datum, index) => {
      const column = index % columns
      const row = Math.floor(index / columns)
      const bubbleCenter: Point = [
        chart.x + padding + cellWidth * (column + 0.5),
        chart.y + padding + cellHeight * (row + 0.5),
      ]
      const bubbleRadius =
        Math.min(cellWidth, cellHeight) * 0.4 * Math.sqrt(datum.value / maximum)
      return {
        points: sampleCircle(bubbleCenter, bubbleRadius, sampleCount),
        center: bubbleCenter,
      }
    })
  }

  const gap = 0.035
  let angle = -Math.PI / 2
  const total = data.reduce((sum, datum) => sum + datum.value, 0)
  return data.map((datum, index) => {
    const span =
      mode === 'rose'
        ? (Math.PI * 2) / data.length
        : (Math.PI * 2 * datum.value) / total
    const start = mode === 'rose' ? -Math.PI / 2 + index * span : angle
    const end = start + span
    angle = end
    const outer =
      mode === 'rose' ? radius * (0.38 + (0.6 * datum.value) / maximum) : radius
    const inner = mode === 'rose' ? radius * 0.09 : radius * 0.55
    const points = sampleSector(
      center,
      inner,
      outer,
      start + gap,
      end - gap,
      sampleCount,
    )
    const middleAngle = (start + end) / 2
    const middleRadius = (inner + outer) / 2
    return {
      points,
      center: [
        center[0] + Math.cos(middleAngle) * middleRadius,
        center[1] + Math.sin(middleAngle) * middleRadius,
      ] as Point,
    }
  })
}

function sampleRectangle(
  x: number,
  y: number,
  width: number,
  height: number,
  count: number,
): Point[] {
  const perimeter = 2 * (width + height)
  return Array.from({ length: count }, (_value, index) => {
    let distance = (index / count) * perimeter
    if (distance < width) return [x + distance, y] as const
    distance -= width
    if (distance < height) return [x + width, y + distance] as const
    distance -= height
    if (distance < width) return [x + width - distance, y + height] as const
    return [x, y + height - (distance - width)] as const
  })
}

function sampleCircle(center: Point, radius: number, count: number): Point[] {
  return Array.from({ length: count }, (_value, index) => {
    const angle = -Math.PI / 2 + (index / count) * Math.PI * 2
    return [
      center[0] + Math.cos(angle) * radius,
      center[1] + Math.sin(angle) * radius,
    ] as const
  })
}

function sampleSector(
  center: Point,
  inner: number,
  outer: number,
  start: number,
  end: number,
  count: number,
): Point[] {
  const outerCount = count / 2
  const innerCount = count - outerCount
  const points: Point[] = []
  for (let index = 0; index < outerCount; index += 1) {
    const angle = start + ((end - start) * index) / (outerCount - 1)
    points.push([
      center[0] + Math.cos(angle) * outer,
      center[1] + Math.sin(angle) * outer,
    ])
  }
  for (let index = 0; index < innerCount; index += 1) {
    const angle = end - ((end - start) * index) / (innerCount - 1)
    points.push([
      center[0] + Math.cos(angle) * inner,
      center[1] + Math.sin(angle) * inner,
    ])
  }
  return points
}

export function modeForRevision(revision: number) {
  return morphModes[Math.abs(revision) % morphModes.length] ?? 'bars'
}

export function modeLabel(mode: MorphMode) {
  return mode[0]!.toUpperCase() + mode.slice(1)
}

export default function GeometryMorphExample({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const input = { width, height, revision, preview: false, interactive: true }
  const idPrefix = '116-geometry-morph'
  const viewRef = useRef<HTMLDivElement>(null)

  const nextRef = useRef<HTMLButtonElement>(null)

  const interruptRef = useRef<HTMLButtonElement>(null)

  const replayRef = useRef<HTMLButtonElement>(null)

  const timerRef = useRef<number>(undefined)

  const [mode, setMode] = useState<MorphMode>(() =>
    modeForRevision(input.revision),
  )

  const [replayCount, setReplayCount] = useState(1)

  const [, setInterruptionCount] = useState(0)

  const [announcement, setAnnouncement] = useState('')

  const renderer = useMemo(() => motion(), [replayCount])

  const definition = useMemo(
    () => geometryMorphDefinition(morphData, mode),
    [mode],
  )

  const clearTimer = () => {
    if (timerRef.current !== undefined) window.clearTimeout(timerRef.current)
    timerRef.current = undefined
  }

  const selectMode = (nextMode: MorphMode) => {
    clearTimer()
    setMode(nextMode)
    setAnnouncement('')
  }

  const advance = () => {
    const index = morphModes.indexOf(mode)
    selectMode(morphModes[(index + 1) % morphModes.length] ?? 'bars')
  }

  const interrupt = () => {
    clearTimer()
    setMode('rose')
    setAnnouncement('Rose → bubbles in 180 ms')
    timerRef.current = window.setTimeout(() => {
      setMode('bubbles')
      setInterruptionCount((value) => value + 1)
      setAnnouncement('')
      timerRef.current = undefined
    }, 180)
  }

  const replay = () => {
    clearTimer()
    setMode('bars')
    setReplayCount((value) => value + 1)
    setAnnouncement('')
  }

  useEffect(() => {
    clearTimer()
    setMode(modeForRevision(input.revision))
    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="Geometry morph controls">
        {morphModes.map((candidate) => (
          <ControlButton
            key={candidate}
            style={{ padding: '0 12px' }}
            onClick={() => selectMode(candidate)}
          >
            {modeLabel(candidate)}
          </ControlButton>
        ))}
        <ControlButton ref={nextRef} onClick={advance}>
          Next
        </ControlButton>
        <ControlButton ref={interruptRef} onClick={interrupt}>
          Interrupt
        </ControlButton>
        <ControlButton ref={replayRef} onClick={replay}>
          Replay
        </ControlButton>
        <output
          aria-live="polite"
          style={{
            display: 'inline-block',
            width: 150,
            overflow: 'hidden',
            whiteSpace: 'nowrap',
            textOverflow: 'ellipsis',
            opacity: 0.7,
          }}
        >
          {announcement || modeLabel(mode)}
        </output>
      </ControlBar>
      <Chart
        key={replayCount}
        idPrefix={idPrefix}
        definition={definition}
        renderer={renderer}
        width={input.width}
        height={Math.max(220, input.height - 96)}
        ariaLabel={`Data morphing as ${mode}`}
        style={{ minHeight: 0 }}
      />
    </div>
  )
}
cases/116-geometry-morph/model.ts24 lines · dependency
cases/116-geometry-morph/model.ts
export interface MorphDatum {
  id: string
  label: string
  value: number
  color: string
}

export type MorphMode = 'bars' | 'rose' | 'donut' | 'bubbles'

export const morphModes: readonly MorphMode[] = [
  'bars',
  'rose',
  'donut',
  'bubbles',
]

export const morphData: readonly MorphDatum[] = [
  { id: 'violet', label: 'V', value: 88, color: '#7c3aed' },
  { id: 'blue', label: 'B', value: 63, color: '#2563eb' },
  { id: 'cyan', label: 'C', value: 74, color: '#0891b2' },
  { id: 'green', label: 'G', value: 51, color: '#059669' },
  { id: 'orange', label: 'O', value: 81, color: '#ea580c' },
  { id: 'pink', label: 'P', value: 68, color: '#db2777' },
]