TanStack
Catalog

Wheel zoom and pan over AAPL closes

interaction

371 lines · 2 files · 10.5 kB

cases/90-zoomable-time-window/example.tsx268 lines · entry
cases/90-zoomable-time-window/example.tsx
import { useCallback, useMemo, useRef, useState } from 'react'
import { Chart } from '@tanstack/charts/react'
import { initialZoomWindow } from './model'
import type { AaplRow } from '@tanstack/charts-data/aapl'
import type { ChartScene } from '@tanstack/charts'
import type {
  ZoomXChange,
  ZoomXWindow,
} from '@tanstack/charts/interaction/zoom'
import { aapl } from '@tanstack/charts-data/aapl'
import { defineChart, dot, lineY } from '@tanstack/charts'
import { zoomX } from '@tanstack/charts/interaction/zoom'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { decorative } from '@tanstack/charts/mark/decorative'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
  selectZoomRows,
  visibleZoomData,
  zoomDateKey,
  zoomFullDomain,
  zoomSpanDays,
} from './model'
import type { ZoomXAction } from '@tanstack/charts/interaction/zoom'
export interface ExampleProps {
  width?: number
  height?: number
  revision?: number
}

export interface ZoomState {
  window: ZoomXWindow<Date>
  lastAction: 'none' | ZoomXAction
  active: boolean
  wheelCaptured: boolean
}

const color = '#0f766e'

const zoomRows = selectZoomRows(aapl)

const zoomDateFormatter = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  timeZone: 'UTC',
})

export function zoomTimeWindowDefinition(
  window: ZoomXWindow<Date>,
  onChange: (window: ZoomXWindow<Date>, reason: ZoomXChange<Date>) => void,
  onActiveChange?: (active: boolean) => void,
) {
  const rows = visibleZoomData(zoomRows, window)
  return defineChart({
    marks: [
      decorative(
        lineY(rows, {
          id: 'zoom-series-line',
          x: 'Date',
          y: 'Close',
          stroke: color,
          strokeWidth: 2.5,
        }),
      ),
      dot(rows, {
        id: 'zoom-series-points',
        x: 'Date',
        y: 'Close',
        fill: color,
        r: 3.5,
        stroke: '#ffffff',
        strokeWidth: 1,
      }),
    ],
    x: {
      scale: scaleUtc().domain([window.start, window.end]),
      axis: {
        ticks: { format: (value) => zoomDateFormatter.format(value) },
        label: 'Date',
      },
    },
    y: {
      scale: scaleLinear,
      grid: true,
      axis: { ticks: { count: 4 }, label: 'AAPL close ($)' },
    },
    controls: [
      zoomX({
        id: 'time-window',
        window: controlledSignal<ZoomXWindow<Date>, ZoomXChange<Date>>(
          window,
          (next, { reason }) => onChange(next, reason),
        ),
        extent: zoomFullDomain,
        scaleExtent: [1, 8],
        ariaLabel:
          'Zoomable time window. Focus the chart before wheel zoom; drag or use a horizontal wheel to pan; use plus, minus, arrow keys, or Home.',
        ariaDescription:
          'Wheel zoom; drag or horizontal wheel pan; plus and minus zoom; arrows pan; Home resets.',
        format: zoomDateKey,
        onActiveChange,
      }),
    ],
    svgAnimation: false,
    keyboard: false,
    focusRing: false,
    margin: { top: 56, right: 24, bottom: 44, left: 58 },
  })
}

export function zoomStatusLabel(state: ZoomState) {
  return state.active
    ? `${zoomDateKey(state.window.start)}${zoomDateKey(state.window.end)} · ${formatSpan(zoomSpanDays(state.window))} days`
    : 'Focus chart to zoom'
}

export function copyWindow(window: ZoomXWindow<Date>): ZoomXWindow<Date> {
  return {
    start: new Date(window.start.getTime()),
    end: new Date(window.end.getTime()),
  }
}

function formatSpan(days: number) {
  return Number.isInteger(days) ? String(days) : days.toFixed(1)
}

export default function ZoomTimeWindowExample({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const input = { width, height, revision, preview: false, interactive: true }
  const idPrefix = '90-zoomable-time-window'
  const shellRef = useRef<HTMLDivElement>(null)

  const chartRef = useRef<HTMLDivElement>(null)

  const sceneRef = useRef<ChartScene<AaplRow, Date, number>>(null)

  const [accepted, setAccepted] = useState(() => copyWindow(initialZoomWindow))

  const [state, setState] = useState<ZoomState>(() => ({
    window: copyWindow(initialZoomWindow),
    lastAction: 'none',
    active: false,
    wheelCaptured: false,
  }))

  const stateRef = useRef(state)

  stateRef.current = state

  const handleZoomChange = useCallback(
    (next: ZoomXWindow<Date>, reason: ZoomXChange<Date>) => {
      const nextWindow = copyWindow(next)
      const nextState: ZoomState = {
        ...stateRef.current,
        window: nextWindow,
        lastAction: reason.action,
        wheelCaptured:
          stateRef.current.wheelCaptured || reason.source === 'wheel',
      }
      stateRef.current = nextState
      setAccepted(nextWindow)
      setState(nextState)
    },
    [],
  )

  const handleActiveChange = useCallback((active: boolean) => {
    const nextState = { ...stateRef.current, active }
    stateRef.current = nextState
    setState(nextState)
  }, [])

  const definition = useMemo(
    () =>
      zoomTimeWindowDefinition(accepted, handleZoomChange, handleActiveChange),
    [accepted, handleActiveChange, handleZoomChange],
  )

  const reset = () => {
    const nextWindow = copyWindow(initialZoomWindow)
    const nextState: ZoomState = {
      ...stateRef.current,
      window: nextWindow,
      lastAction: 'reset',
    }
    stateRef.current = nextState
    setAccepted(nextWindow)
    setState(nextState)
    chartRef.current
      ?.querySelector<SVGElement>('[data-chart-zoom-surface]')
      ?.focus()
  }

  return (
    <div
      ref={shellRef}
      data-conformance-view="main"
      style={{ position: 'relative', width: input.width, height: input.height }}
    >
      <div
        ref={chartRef}
        style={{
          position: 'relative',
          width: input.width,
          height: input.height,
        }}
      >
        <Chart
          idPrefix={idPrefix}
          definition={definition}
          width={input.width}
          height={input.height}
          ariaLabel="Time series with a wheel-zoomable and pannable time viewport"
          onRender={({ scene }) => {
            sceneRef.current = scene
          }}
        />
      </div>
      <output
        data-conformance-zoom-status="true"
        role="status"
        aria-live="polite"
        style={{
          position: 'absolute',
          top: 10,
          right: 76,
          zIndex: 4,
          padding: '4px 8px',
          border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)',
          borderRadius: 999,
          background: 'Canvas',
          color: 'CanvasText',
          font: '600 12px/1.2 system-ui, sans-serif',
          pointerEvents: 'none',
        }}
      >
        {zoomStatusLabel(state)}
      </output>
      <button
        type="button"
        data-conformance-zoom-reset="true"
        title="Reset zoom"
        aria-label="Reset zoom"
        onPointerDown={(event) => event.preventDefault()}
        onClick={reset}
        style={{
          position: 'absolute',
          top: 6,
          right: 20,
          zIndex: 4,
          width: 44,
          height: 44,
          border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)',
          borderRadius: 10,
          background: 'Canvas',
          color: 'CanvasText',
          cursor: 'pointer',
          font: '700 20px/1 system-ui, sans-serif',
        }}
      >
        ↺
      </button>
    </div>
  )
}
cases/90-zoomable-time-window/model.ts103 lines · dependency
cases/90-zoomable-time-window/model.ts
import type { AaplRow } from '@tanstack/charts-data/aapl'

export interface ZoomWindow {
  start: Date
  end: Date
}

export const zoomFullDomain: readonly [Date, Date] = [
  new Date(Date.UTC(2018, 0, 2)),
  new Date(Date.UTC(2018, 0, 18)),
]

export function selectZoomRows(rows: readonly AaplRow[]): readonly AaplRow[] {
  const start = zoomFullDomain[0].getTime()
  const end = zoomFullDomain[1].getTime()
  return rows.filter((row) => {
    const timestamp = row.Date.getTime()
    return timestamp >= start && timestamp <= end
  })
}

export const millisecondsPerDay = 24 * 60 * 60 * 1_000

export const initialZoomWindow: ZoomWindow = {
  start: zoomFullDomain[0],
  end: zoomFullDomain[1],
}

export function zoomDateKey(date: Date) {
  return date.toISOString().slice(0, 10)
}

export function zoomDateFromAnchor(rows: readonly AaplRow[], anchor: string) {
  const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
  return rows.find((row) => zoomDateKey(row.Date) === key)?.Date ?? null
}

export function visibleZoomData(rows: readonly AaplRow[], window: ZoomWindow) {
  const start = window.start.getTime()
  const end = window.end.getTime()
  return rows.filter((row) => {
    const timestamp = row.Date.getTime()
    return timestamp >= start && timestamp <= end
  })
}

export function zoomSpanDays(window: ZoomWindow) {
  return (window.end.getTime() - window.start.getTime()) / millisecondsPerDay
}

export function zoomWindowAt(
  window: ZoomWindow,
  anchor: Date,
  factor: number,
): ZoomWindow {
  const start = window.start.getTime()
  const end = window.end.getTime()
  const span = end - start
  const fullSpan = zoomFullDomain[1].getTime() - zoomFullDomain[0].getTime()
  const nextSpan = Math.min(
    fullSpan,
    Math.max(2 * millisecondsPerDay, span * factor),
  )
  if (nextSpan >= fullSpan) return { ...initialZoomWindow }
  const anchorTime = Math.min(end, Math.max(start, anchor.getTime()))
  const ratio = span === 0 ? 0.5 : (anchorTime - start) / span
  return clampZoomWindow(anchorTime - ratio * nextSpan, nextSpan)
}

export function panZoomWindow(
  window: ZoomWindow,
  direction: -1 | 1,
): ZoomWindow {
  const span = window.end.getTime() - window.start.getTime()
  return clampZoomWindow(window.start.getTime() + direction * span * 0.25, span)
}

export function shiftZoomWindow(
  window: ZoomWindow,
  deltaMilliseconds: number,
): ZoomWindow {
  const span = window.end.getTime() - window.start.getTime()
  return clampZoomWindow(window.start.getTime() + deltaMilliseconds, span)
}

function clampZoomWindow(start: number, span: number): ZoomWindow {
  const domainStart = zoomFullDomain[0].getTime()
  const domainEnd = zoomFullDomain[1].getTime()
  let nextStart = start
  let nextEnd = start + span
  if (nextStart < domainStart) {
    nextEnd += domainStart - nextStart
    nextStart = domainStart
  }
  if (nextEnd > domainEnd) {
    nextStart -= nextEnd - domainEnd
    nextEnd = domainEnd
  }
  return {
    start: new Date(Math.max(domainStart, nextStart)),
    end: new Date(Math.min(domainEnd, nextEnd)),
  }
}