TanStack
Catalog

Timeline playback scrubber

interaction

TanStack

benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts
import { defineChart, dot, lineY, mountChart } from '@tanstack/charts'
import { focusDisabled } from '@tanstack/charts/focus/disabled'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
  initialPlaybackIndex,
  playbackData,
  playbackDateKey,
  playbackDomain,
  playbackIndexFromAnchor,
} from './data'
import { createPlaybackOverlay } from './overlay'
import type {
  ChartHost,
  ChartScene,
  DynamicChartHostOptions,
} from '@tanstack/charts'
import type { PlaybackDatum } from './data'
import type { PlaybackOverlayLayout } from './overlay'
import type {
  ConformanceGeometryQuery,
  ConformanceGeometrySample,
  ConformanceInput,
  ConformanceMount,
  ConformanceTarget,
  ConformanceTestDriver,
} from '../../types'

interface PlaybackState {
  index: number
  dragging: boolean
  scrubCount: number
  playing: boolean
  originIndex: number | null
}

const linePaint = '#2563eb'
const yDomain: readonly [number, number] = [20, 80]
const margin = { top: 64, right: 24, bottom: 68, left: 56 }

const definition = defineChart<ConformanceInput>()(({ input }) => {
  const rows = playbackData(input.revision)
  return {
    marks: [
      lineY(rows, {
        id: 'playback-series',
        x: 'date',
        y: 'value',
        key: 'id',
        stroke: linePaint,
        strokeWidth: 2.5,
      }),
      dot(rows, {
        id: 'playback-points',
        x: 'date',
        y: 'value',
        key: 'id',
        fill: linePaint,
        r: 3.5,
        stroke: '#ffffff',
        strokeWidth: 1,
      }),
    ],
    x: {
      scale: scaleUtc().domain(playbackDomain),
      format: (value) =>
        value.toLocaleDateString(undefined, {
          month: 'short',
          day: 'numeric',
          timeZone: 'UTC',
        }),
    },
    y: {
      scale: scaleLinear().domain(yDomain),
      ticks: 4,
      grid: true,
    },
    margin,
  }
})

export const mount: ConformanceMount = (container, input) => {
  let currentInput = input
  const state: PlaybackState = {
    index: initialPlaybackIndex,
    dragging: false,
    scrubCount: 0,
    playing: false,
    originIndex: null,
  }
  const view = container.ownerDocument.createElement('div')
  view.dataset.conformanceView = 'main'
  view.style.position = 'relative'
  view.style.touchAction = 'pan-y'
  sizeView(view, input)

  const chartSurface = container.ownerDocument.createElement('div')
  view.append(chartSurface)
  container.append(view)

  const options = (): DynamicChartHostOptions<
    PlaybackDatum,
    ConformanceInput
  > => ({
    definition,
    input: currentInput,
    width: currentInput.width,
    height: currentInput.height,
    ariaLabel: 'Weekly values with a draggable timeline playback scrubber',
    animate: false,
    keyboard: false,
    focus: focusDisabled,
  })
  const host = mountChart(chartSurface, options())
  const interactions = createPlaybackInteractions(
    chartSurface,
    view,
    () => currentInput,
    state,
    host,
  )
  interactions.paint()

  return {
    driver: interactions.driver,
    update(nextInput) {
      currentInput = nextInput
      sizeView(view, nextInput)
      host.update(options())
      interactions.paint()
    },
    destroy() {
      interactions.destroy()
      host.destroy()
      view.remove()
    },
  }
}

function createPlaybackInteractions(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  getInput: () => ConformanceInput,
  state: PlaybackState,
  host: ChartHost<PlaybackDatum, ConformanceInput>,
) {
  let playbackTimer: ReturnType<typeof setInterval> | null = null
  let paint = () => {}
  let announce = (_message: string) => {}
  const frameText = () => {
    const row = playbackData(getInput().revision)[state.index]
    return row ? `${playbackDateKey(row.date)} · ${row.value}` : 'No frame'
  }
  const stopPlayback = (message?: string) => {
    if (playbackTimer !== null) {
      clearInterval(playbackTimer)
      playbackTimer = null
    }
    state.playing = false
    paint()
    if (message) announce(`${message}. ${frameText()}`)
  }
  const togglePlayback = () => {
    if (state.playing) {
      stopPlayback('Playback paused')
      return
    }
    const lastIndex = playbackData(getInput().revision).length - 1
    const restarting = state.index >= lastIndex
    if (restarting) state.index = 0
    state.playing = true
    playbackTimer = setInterval(() => {
      const nextLastIndex = playbackData(getInput().revision).length - 1
      if (state.index >= nextLastIndex) {
        stopPlayback('Playback ended')
        return
      }
      state.index += 1
      paint()
    }, 700)
    paint()
    announce(
      `${restarting ? 'Playback restarted' : 'Playback started'}. ${frameText()}`,
    )
  }
  const beginChange = () => {
    if (state.dragging) return
    stopPlayback()
    state.originIndex = state.index
    state.dragging = true
  }
  const overlay = createPlaybackOverlay(
    view,
    (index) => {
      beginChange()
      state.index = Math.max(
        0,
        Math.min(playbackData(getInput().revision).length - 1, index),
      )
      paint()
    },
    togglePlayback,
  )
  announce = overlay.announce
  const layout = () =>
    playbackLayout(chartSurface, view, host.getScene(), state.index)

  paint = () => {
    const nextLayout = layout()
    const row = playbackData(getInput().revision)[state.index]
    if (!nextLayout || !row) return
    overlay.paint(nextLayout, {
      index: state.index,
      max: playbackData(getInput().revision).length - 1,
      playing: state.playing,
      valueText: `${playbackDateKey(row.date)} · ${row.value}`,
    })
  }

  const handlePointerDown = () => {
    beginChange()
  }

  const commitChange = () => {
    if (!state.dragging) return
    state.dragging = false
    state.originIndex = null
    state.scrubCount += 1
    paint()
    announce(`Frame selected. ${frameText()}`)
  }

  const cancelChange = () => {
    if (!state.dragging) return
    if (state.originIndex !== null) state.index = state.originIndex
    state.dragging = false
    state.originIndex = null
    paint()
    announce(`Scrub canceled. ${frameText()}`)
  }

  overlay.range.addEventListener('pointerdown', handlePointerDown)
  overlay.range.addEventListener('change', commitChange)
  overlay.range.addEventListener('pointercancel', cancelChange)

  const driver: ConformanceTestDriver = {
    resolveTarget(target) {
      return resolveTarget(view, layout(), target)
    },
    readState() {
      return interactionState(state, getInput())
    },
    geometry(query) {
      return playbackGeometry(
        chartSurface,
        view,
        host.getScene(),
        getInput(),
        layout(),
        overlay.ruleGeometry(),
        query,
      )
    },
    viewBounds(viewName) {
      return viewName === undefined || viewName === 'main'
        ? clientBounds(view)
        : null
    },
    settle: paint,
  }

  return {
    driver,
    paint,
    destroy() {
      if (playbackTimer !== null) clearInterval(playbackTimer)
      overlay.range.removeEventListener('pointerdown', handlePointerDown)
      overlay.range.removeEventListener('change', commitChange)
      overlay.range.removeEventListener('pointercancel', cancelChange)
      overlay.destroy()
    },
  }
}

function resolveTarget(
  view: HTMLDivElement,
  layout: PlaybackOverlayLayout | null,
  target: ConformanceTarget,
) {
  if (!layout || (target.view !== undefined && target.view !== 'main')) {
    return null
  }
  if (target.anchor === 'control:play') {
    const button = view.querySelector<HTMLButtonElement>(
      'button[aria-label$="timeline"]',
    )
    if (!button) return null
    const bounds = button.getBoundingClientRect()
    return {
      x: bounds.left + bounds.width / 2,
      y: bounds.top + bounds.height / 2,
      focusElement: button,
    }
  }
  const index = playbackIndexFromAnchor(target.anchor)
  const x = index === null ? undefined : layout.frameXs[index]
  if (x === undefined) return null
  const bounds = view.getBoundingClientRect()
  return {
    x: bounds.left + x,
    y: bounds.top + layout.trackY,
    focusElement:
      view.querySelector<HTMLInputElement>('.ts-conformance-playback-range') ??
      undefined,
  }
}

function interactionState(state: PlaybackState, input: ConformanceInput) {
  const rows = playbackData(input.revision)
  const row = rows[state.index]
  return {
    playhead: {
      index: state.index,
      date: row ? playbackDateKey(row.date) : null,
      value: row?.value ?? null,
      progress: rows.length > 1 ? state.index / (rows.length - 1) : 0,
    },
    frames: {
      count: rows.length,
      ids: rows.map((datum) => datum.id),
      revisionProbeValue: rows[3]?.value ?? null,
    },
    interaction: {
      dragging: state.dragging,
      scrubCount: state.scrubCount,
      playing: state.playing,
    },
  }
}

function playbackGeometry(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<PlaybackDatum>,
  input: ConformanceInput,
  layout: PlaybackOverlayLayout | null,
  overlayRules: readonly ConformanceGeometrySample[],
  query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
  if (!layout || (query.view !== undefined && query.view !== 'main')) {
    return []
  }
  const viewBounds = view.getBoundingClientRect()
  const points = playbackData(input.revision).flatMap((row) => {
    const point = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.date),
      scene.scales.y.map(row.value),
    )
    return point ? [point] : []
  })

  if (query.role === 'dot') {
    return points.map((point) => ({
      x: viewBounds.left + point[0] - 3.5,
      y: viewBounds.top + point[1] - 3.5,
      width: 7,
      height: 7,
      paint: linePaint,
    }))
  }
  if (query.role === 'line') {
    const sample = pointsBounds(points, viewBounds, linePaint)
    return sample ? [sample] : []
  }
  if (query.role === 'rule') return overlayRules
  return []
}

function playbackLayout(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<PlaybackDatum>,
  index: number,
): PlaybackOverlayLayout | null {
  const first = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.chart.x,
    scene.chart.y + scene.chart.height,
  )
  const last = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.chart.x + scene.chart.width,
    scene.chart.y + scene.chart.height,
  )
  const top = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.chart.x,
    scene.chart.y,
  )
  if (!first || !last || !top) return null
  const frameXs = playbackData().flatMap((row) => {
    const point = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.date),
      scene.chart.y + scene.chart.height,
    )
    return point ? [point[0]] : []
  })
  const playheadX = frameXs[index]
  if (playheadX === undefined || frameXs.length !== playbackData().length) {
    return null
  }
  return {
    left: first[0],
    right: last[0],
    top: top[1],
    bottom: first[1],
    trackY: first[1] + 34,
    playheadX,
    frameXs,
  }
}

function sceneLocalPoint(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<PlaybackDatum>,
  sceneX: number,
  sceneY: number,
): readonly [number, number] | null {
  const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
  if (!svg) return null
  const svgBounds = svg.getBoundingClientRect()
  const viewBounds = view.getBoundingClientRect()
  return [
    svgBounds.left - viewBounds.left + (sceneX / scene.width) * svgBounds.width,
    svgBounds.top - viewBounds.top + (sceneY / scene.height) * svgBounds.height,
  ]
}

function pointsBounds(
  points: readonly (readonly [number, number])[],
  viewBounds: DOMRect,
  paint: string,
): ConformanceGeometrySample | null {
  if (!points.length) return null
  const xs = points.map((point) => point[0])
  const ys = points.map((point) => point[1])
  const left = Math.min(...xs)
  const right = Math.max(...xs)
  const top = Math.min(...ys)
  const bottom = Math.max(...ys)
  return {
    x: viewBounds.left + left,
    y: viewBounds.top + top,
    width: Math.max(1, right - left),
    height: Math.max(1, bottom - top),
    paint,
  }
}

function clientBounds(element: HTMLElement): ConformanceGeometrySample {
  const bounds = element.getBoundingClientRect()
  return {
    x: bounds.left,
    y: bounds.top,
    width: bounds.width,
    height: bounds.height,
  }
}

function sizeView(view: HTMLDivElement, input: ConformanceInput) {
  view.style.width = `${input.width}px`
  view.style.height = `${input.height}px`
}