TanStack
Catalog

AAPL close playback scrubber

interaction

1,413 lines · 8 files · 37.7 kB

cases/91-timeline-playback-scrubber/model.ts36 lines · dependency
cases/91-timeline-playback-scrubber/model.ts
import type { AaplRow } from '@charts-poc/demo-data/aapl'

export const initialPlaybackIndex = 2

export function selectPlaybackRows(
  rows: readonly AaplRow[],
): readonly AaplRow[] {
  const start = Date.UTC(2018, 0, 2)
  const end = Date.UTC(2018, 0, 11)
  return rows.filter((row) => {
    const timestamp = row.Date.getTime()
    return timestamp >= start && timestamp <= end
  })
}

export function playbackDomain(
  rows: readonly AaplRow[],
): readonly [Date, Date] {
  const first = rows[0]
  const last = rows.at(-1)
  if (!first || !last) throw new Error('Playback requires observed AAPL rows.')
  return [first.Date, last.Date]
}

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

export function playbackIndexFromAnchor(
  rows: readonly AaplRow[],
  anchor: string,
) {
  const key = anchor.startsWith('frame:') ? anchor.slice(6) : ''
  const index = rows.findIndex((row) => playbackDateKey(row.Date) === key)
  return index < 0 ? null : index
}
cases/91-timeline-playback-scrubber/tanstack.ts288 lines · entry
cases/91-timeline-playback-scrubber/tanstack.ts
import { defineChart, dot, lineY } from '@tanstack/charts'
import { aapl } from '@charts-poc/demo-data/aapl'
import { handleX } from '@tanstack/charts/interaction/handle'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { decorative } from '@tanstack/charts/mark/decorative'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
  clientPointBounds,
  scenePointToClient,
} from '../../shared/driver-geometry'
import {
  initialPlaybackIndex,
  playbackDateKey,
  playbackIndexFromAnchor,
  selectPlaybackRows,
} from './model'
import { tanstackCase } from '../../shared/mount'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { ChartScene } from '@tanstack/charts'
import type {
  ConformanceGeometryQuery,
  ConformanceGeometrySample,
  ConformanceJsonObject,
  ConformanceTarget,
  ConformanceTestDriver,
} from '../../types'

export interface PlaybackState {
  frame: Date
  dragging: boolean
  scrubCount: number
  playing: boolean
}

const linePaint = '#2563eb'
const playheadPaint = '#f97316'
const margin = { top: 64, right: 24, bottom: 68, left: 56 }
export const playbackRows = selectPlaybackRows(aapl)
const playbackDates = playbackRows.map((row) => row.Date)
export const initialFrame = playbackRows[initialPlaybackIndex]?.Date
if (!initialFrame) throw new Error('Playback requires an initial frame.')

export function playbackDefinition(
  frame: Date,
  onChange: (value: Date, reason: HandleXChange<Date>) => void,
  preview = false,
) {
  return defineChart({
    marks: [
      decorative(
        lineY(playbackRows, {
          id: 'playback-line',
          x: 'Date',
          y: 'Close',
          stroke: linePaint,
          strokeWidth: 2.5,
        }),
      ),
      dot(playbackRows, {
        id: 'playback-points',
        x: 'Date',
        y: 'Close',
        fill: linePaint,
        r: 3.5,
        stroke: '#ffffff',
        strokeWidth: 1,
      }),
    ],
    x: {
      scale: scaleUtc,
      axis: {
        ticks: {
          format: (value) =>
            value.toLocaleDateString(undefined, {
              month: 'short',
              day: 'numeric',
              timeZone: 'UTC',
            }),
        },
      },
    },
    y: {
      scale: scaleLinear,
      grid: true,
      axis: { ticks: { count: 4 }, label: 'AAPL close ($)' },
    },
    controls: [
      handleX({
        id: 'playback-frame',
        value: controlledSignal<Date, HandleXChange<Date>>(
          frame,
          (next, { reason }) => onChange(next, reason),
        ),
        values: playbackDates,
        cross: { edge: 'bottom', offset: preview ? -18 : 34 },
        trackStyle: {
          fill: 'color-mix(in srgb, currentColor 52%, transparent)',
        },
        ruleStyle: { fill: playheadPaint },
        handleStyle: {
          fill: playheadPaint,
          stroke: 'Canvas',
          strokeWidth: 2,
        },
        hitSize: 44,
        ariaLabel: 'Timeline frame',
        format: (value) => playbackValueText(rowForDate(value)),
      }),
    ],
    svgAnimation: false,
    keyboard: false,
    focusRing: false,
    margin: preview ? 0 : margin,
  })
}

export const catalogCase = tanstackCase(
  () => playbackDefinition(initialFrame, () => {}, true),
  'AAPL closes with a draggable timeline playback scrubber',
)

export { mount } from './view'

export function createDriver(
  view: HTMLElement,
  surface: HTMLElement,
  playButton: HTMLButtonElement,
  getScene: () => ChartScene<AaplRow, Date, number>,
  getState: () => PlaybackState,
): ConformanceTestDriver {
  return {
    resolveTarget(target) {
      return resolveTarget(surface, playButton, getScene(), target)
    },
    readState() {
      return interactionState(getState())
    },
    geometry(query) {
      return playbackGeometry(surface, getScene(), query)
    },
    viewBounds(viewName) {
      if (viewName !== undefined && viewName !== 'main') return null
      const bounds = view.getBoundingClientRect()
      return {
        x: bounds.left,
        y: bounds.top,
        width: bounds.width,
        height: bounds.height,
      }
    },
  }
}

function resolveTarget(
  surface: HTMLElement,
  playButton: HTMLButtonElement,
  scene: ChartScene<AaplRow, Date, number>,
  target: ConformanceTarget,
) {
  if (target.view !== undefined && target.view !== 'main') return null
  if (target.anchor === 'control:play') return center(playButton)
  const index = playbackIndexFromAnchor(playbackRows, target.anchor)
  const row = index === null ? undefined : playbackRows[index]
  if (!row) return null
  const point = scenePointToClient(
    surface,
    scene,
    scene.scales.x.map(row.Date),
    scene.chart.y + scene.chart.height + 34,
  )
  if (!point) return null
  return {
    ...point,
    focusElement:
      surface.querySelector<SVGElement>('[data-chart-handle-surface]') ??
      point.focusElement,
  }
}

function interactionState(state: PlaybackState): ConformanceJsonObject {
  const index = indexForDate(state.frame)
  const row = playbackRows[index]
  return {
    playhead: {
      index,
      date: row ? playbackDateKey(row.Date) : null,
      value: row?.Close ?? null,
      progress: playbackRows.length > 1 ? index / (playbackRows.length - 1) : 0,
    },
    frames: {
      count: playbackRows.length,
      ids: playbackRows.map((datum) => playbackDateKey(datum.Date)),
      jan5Close: playbackRows[3]?.Close ?? null,
    },
    interaction: {
      dragging: state.dragging,
      scrubCount: state.scrubCount,
      playing: state.playing,
    },
  }
}

function playbackGeometry(
  surface: HTMLElement,
  scene: ChartScene<AaplRow, Date, number>,
  query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
  if (query.view !== undefined && query.view !== 'main') return []
  const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
  if (!svg) return []
  const bounds = svg.getBoundingClientRect()
  const scaleX = bounds.width / scene.width
  const scaleY = bounds.height / scene.height
  const points = playbackRows.map(
    (row) =>
      [scene.scales.x.map(row.Date), scene.scales.y.map(row.Close)] as const,
  )
  if (query.role === 'dot') {
    return points.map(([x, y]) => ({
      x: bounds.left + (x - 3.5) * scaleX,
      y: bounds.top + (y - 3.5) * scaleY,
      width: 7 * scaleX,
      height: 7 * scaleY,
      paint: linePaint,
    }))
  }
  if (query.role === 'line') {
    const sample = clientPointBounds(points, bounds, {
      scaleX,
      scaleY,
      paint: linePaint,
    })
    return sample ? [sample] : []
  }
  if (query.role !== 'rule') return []
  return ['track', 'rule'].flatMap((part) => {
    const element = surface.querySelector<SVGElement>(
      `[data-chart-handle-${part}]`,
    )
    return element ? [elementGeometry(element)] : []
  })
}

function elementGeometry(element: SVGElement): ConformanceGeometrySample {
  const bounds = element.getBoundingClientRect()
  const style = getComputedStyle(element)
  return {
    x: bounds.left,
    y: bounds.top,
    width: bounds.width,
    height: bounds.height,
    paint: style.fill || style.stroke,
  }
}

export function rowForDate(date: Date) {
  const row = playbackRows.find(
    (candidate) => candidate.Date.getTime() === date.getTime(),
  )
  if (!row) throw new Error('Playback frame must be an observed date.')
  return row
}

export function indexForDate(date: Date) {
  const index = playbackRows.findIndex(
    (row) => row.Date.getTime() === date.getTime(),
  )
  if (index < 0) throw new Error('Playback frame must be an observed date.')
  return index
}

export function playbackValueText(row: AaplRow) {
  return `${playbackDateKey(row.Date)} · AAPL close $${row.Close.toFixed(2)}`
}

export function cloneDate(date: Date) {
  return new Date(date.getTime())
}

function center(element: HTMLElement | SVGElement) {
  const bounds = element.getBoundingClientRect()
  return {
    x: bounds.left + bounds.width / 2,
    y: bounds.top + bounds.height / 2,
    focusElement: element,
  }
}
cases/91-timeline-playback-scrubber/view.tsx263 lines · dependency
cases/91-timeline-playback-scrubber/view.tsx
import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from 'react'
import { Chart } from '@tanstack/charts/react'
import { reactMount } from '../../shared/react-mount'
import {
  cloneDate,
  createDriver,
  indexForDate,
  initialFrame,
  playbackDefinition,
  playbackRows,
  playbackValueText,
  rowForDate,
} from './tanstack'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { ChartScene } from '@tanstack/charts'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { ReactConformanceProps } from '../../shared/react-mount'
import type { ConformanceTestDriver } from '../../types'
import type { PlaybackState } from './tanstack'

const PlaybackExample = forwardRef<
  ConformanceTestDriver,
  ReactConformanceProps
>(function PlaybackExample({ input, idPrefix }, ref) {
  const viewRef = useRef<HTMLDivElement>(null)
  const chartRef = useRef<HTMLDivElement>(null)
  const playRef = useRef<HTMLButtonElement>(null)
  const sceneRef = useRef<ChartScene<AaplRow, Date, number>>(null)
  const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
  const [accepted, setAccepted] = useState(() => cloneDate(initialFrame))
  const [state, setState] = useState<PlaybackState>(() => ({
    frame: cloneDate(initialFrame),
    dragging: false,
    scrubCount: 0,
    playing: false,
  }))
  const [announcement, setAnnouncement] = useState('')
  const stateRef = useRef(state)
  stateRef.current = state

  const commitState = useCallback((next: PlaybackState) => {
    stateRef.current = next
    setState(next)
  }, [])
  const frameText = useCallback(
    (frame = stateRef.current.frame) => playbackValueText(rowForDate(frame)),
    [],
  )
  const stopPlayback = useCallback(
    (message?: string) => {
      if (timerRef.current !== undefined) clearInterval(timerRef.current)
      timerRef.current = undefined
      commitState({ ...stateRef.current, playing: false })
      if (message) setAnnouncement(`${message}. ${frameText()}`)
    },
    [commitState, frameText],
  )
  const applyFrame = useCallback(
    (next: Date) => {
      const frame = cloneDate(next)
      setAccepted(frame)
      commitState({ ...stateRef.current, frame })
    },
    [commitState],
  )
  const handleFrameChange = useCallback(
    (next: Date, reason: HandleXChange<Date>) => {
      if (stateRef.current.playing) stopPlayback()
      if (reason.type === 'preview') {
        commitState({
          ...stateRef.current,
          frame: cloneDate(next),
          dragging: true,
        })
        return
      }
      if (reason.type === 'cancel') {
        const frame = cloneDate(reason.origin)
        commitState({ ...stateRef.current, frame, dragging: false })
        setAnnouncement(`Scrub canceled. ${frameText(frame)}`)
        return
      }
      const frame = cloneDate(next)
      setAccepted(frame)
      commitState({
        ...stateRef.current,
        frame,
        dragging: false,
        scrubCount: stateRef.current.scrubCount + 1,
      })
      setAnnouncement(`Frame selected. ${frameText(frame)}`)
    },
    [commitState, frameText, stopPlayback],
  )
  const definition = useMemo(
    () => playbackDefinition(accepted, handleFrameChange),
    [accepted, handleFrameChange],
  )

  const togglePlayback = useCallback(() => {
    if (stateRef.current.playing) {
      stopPlayback('Playback paused')
      return
    }
    const lastIndex = playbackRows.length - 1
    const restarting = indexForDate(stateRef.current.frame) >= lastIndex
    if (restarting) applyFrame(playbackRows[0]!.Date)
    commitState({ ...stateRef.current, playing: true, dragging: false })
    timerRef.current = setInterval(() => {
      const index = indexForDate(stateRef.current.frame)
      if (index >= playbackRows.length - 1) {
        stopPlayback('Playback ended')
        return
      }
      applyFrame(playbackRows[index + 1]!.Date)
    }, 700)
    setAnnouncement(
      `${restarting ? 'Playback restarted' : 'Playback started'}. ${frameText()}`,
    )
  }, [applyFrame, commitState, frameText, stopPlayback])

  useEffect(
    () => () => {
      if (timerRef.current !== undefined) clearInterval(timerRef.current)
    },
    [],
  )
  useImperativeHandle(ref, () => {
    const view = viewRef.current
    const chart = chartRef.current
    const play = playRef.current
    if (!view || !chart || !play) throw new Error('Missing playback view')
    return createDriver(
      view,
      chart,
      play,
      () => {
        if (!sceneRef.current) throw new Error('Missing playback scene')
        return sceneRef.current
      },
      () => stateRef.current,
    )
  }, [])

  const buttonLabel = state.playing ? 'Pause timeline' : 'Play timeline'
  return (
    <div
      ref={viewRef}
      data-conformance-view="main"
      style={{
        position: 'relative',
        width: input.width,
        height: input.height,
        touchAction: 'pan-y',
      }}
    >
      <div ref={chartRef}>
        <Chart
          idPrefix={idPrefix}
          definition={definition}
          width={input.width}
          height={input.height}
          ariaLabel="AAPL closes with a draggable timeline playback scrubber"
          onRender={({ scene }) => {
            sceneRef.current = scene
          }}
        />
      </div>
      <div
        className="ts-conformance-playback-toolbar"
        role="group"
        aria-label="Timeline playback controls"
        style={{
          position: 'absolute',
          top: 4,
          left: 56,
          right: 20,
          zIndex: 3,
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'flex-end',
          gap: 8,
          pointerEvents: 'none',
        }}
      >
        <div
          className="ts-conformance-playback-current"
          style={{
            boxSizing: 'border-box',
            minWidth: 0,
            minHeight: 32,
            padding: '7px 9px',
            border:
              '1px solid color-mix(in srgb, currentColor 32%, transparent)',
            borderRadius: 999,
            overflow: 'hidden',
            background:
              'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)',
            color: 'inherit',
            textOverflow: 'ellipsis',
            whiteSpace: 'nowrap',
            font: '600 12px/1.2 system-ui, sans-serif',
          }}
        >
          {frameText(state.frame)}
        </div>
        <button
          ref={playRef}
          className="ts-conformance-playback-button"
          type="button"
          aria-pressed={state.playing}
          aria-label={buttonLabel}
          title={buttonLabel}
          onClick={togglePlayback}
          style={{
            flex: '0 0 auto',
            width: 44,
            height: 44,
            border:
              '1px solid color-mix(in srgb, currentColor 32%, transparent)',
            borderRadius: 10,
            background:
              'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)',
            color: 'inherit',
            cursor: 'pointer',
            font: '700 16px/1 system-ui, sans-serif',
            pointerEvents: 'auto',
          }}
        >
          {state.playing ? '❚❚' : '▶'}
        </button>
      </div>
      <output
        className="ts-conformance-playback-announcement"
        role="status"
        aria-live="polite"
        aria-atomic="true"
        style={{
          position: 'absolute',
          width: 1,
          height: 1,
          padding: 0,
          margin: -1,
          overflow: 'hidden',
          clipPath: 'inset(50%)',
          whiteSpace: 'nowrap',
        }}
      >
        {announcement}
      </output>
    </div>
  )
})

export const mount = reactMount(PlaybackExample)
shared/driver-geometry.ts70 lines · dependency
shared/driver-geometry.ts
import type {
  ConformanceGeometrySample,
  ConformanceResolvedTarget,
} from '../types'

export interface ClientPointBoundsOptions {
  paint: string
  scaleX?: number
  scaleY?: number
}

/**
 * Bounds local chart points in viewport-relative client coordinates.
 * Degenerate point clouds retain a one-pixel geometry sample for comparison.
 */
export function clientPointBounds(
  points: readonly (readonly [number, number])[],
  origin: Pick<DOMRectReadOnly, 'left' | 'top'>,
  options: ClientPointBoundsOptions,
): ConformanceGeometrySample | null {
  if (!points.length) return null

  let left = Number.POSITIVE_INFINITY
  let right = Number.NEGATIVE_INFINITY
  let top = Number.POSITIVE_INFINITY
  let bottom = Number.NEGATIVE_INFINITY
  for (const [x, y] of points) {
    left = Math.min(left, x)
    right = Math.max(right, x)
    top = Math.min(top, y)
    bottom = Math.max(bottom, y)
  }

  const scaleX = options.scaleX ?? 1
  const scaleY = options.scaleY ?? 1
  return {
    x: origin.left + left * scaleX,
    y: origin.top + top * scaleY,
    width: Math.max(1, (right - left) * scaleX),
    height: Math.max(1, (bottom - top) * scaleY),
    paint: options.paint,
  }
}

/** Maps one outer-scene coordinate through the mounted SVG viewport. */
export function scenePointToClient(
  surface: ParentNode,
  scene: { readonly width: number; readonly height: number },
  x: number,
  y: number,
): ConformanceResolvedTarget | null {
  const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
  if (
    !svg ||
    !Number.isFinite(scene.width) ||
    !Number.isFinite(scene.height) ||
    scene.width <= 0 ||
    scene.height <= 0 ||
    !Number.isFinite(x) ||
    !Number.isFinite(y)
  ) {
    return null
  }
  const bounds = svg.getBoundingClientRect()
  return {
    x: bounds.left + (x / scene.width) * bounds.width,
    y: bounds.top + (y / scene.height) * bounds.height,
    focusElement: svg,
  }
}
shared/mount.ts179 lines · dependency
shared/mount.ts
import {
  defineChart,
  isResponsiveChartDefinition,
  mountChart,
} from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import type {
  DomChartDefinition,
  ChartDefinitionOptions,
  ChartValue,
  ChartTooltipOptions,
} from '@tanstack/charts'
import type {
  ConformanceHandle,
  ConformanceInput,
  ConformanceMount,
} from '../types'
import { catalogPreviewDefinition, type CatalogPreviewOptions } from './preview'

export function mountObservablePlot(
  container: HTMLElement,
  input: ConformanceInput,
  render: (input: ConformanceInput) => HTMLElement | SVGSVGElement,
): ConformanceHandle {
  let element = render(input)
  container.append(element)

  return {
    update(nextInput) {
      const nextElement = render(nextInput)
      element.replaceWith(nextElement)
      element = nextElement
    },
    destroy() {
      element.remove()
    },
  }
}

export function tanstackMount<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  const mount: ConformanceMount = (container, input) => {
    const options = {
      definition: withConformanceBehavior(
        createDefinition(input),
        input,
        interactiveTooltip,
        previewOptions,
      ),
      width: input.width,
      height: input.height,
      ariaLabel,
    } as const
    const host = mountChart(container, options)
    applyCatalogPreviewFocus(host, input, previewOptions)

    return {
      update(nextInput) {
        host.update({
          ...options,
          definition: withConformanceBehavior(
            createDefinition(nextInput),
            nextInput,
            interactiveTooltip,
            previewOptions,
          ),
          width: nextInput.width,
          height: nextInput.height,
        })
        applyCatalogPreviewFocus(host, nextInput, previewOptions)
      },
      destroy() {
        host.destroy()
      },
    }
  }

  const catalogCase = Object.assign(mount, {
    createDefinition,
    ariaLabel,
    interactiveTooltip,
  })

  return Object.assign(catalogCase, { mount: catalogCase })
}

export interface TanStackConformanceCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  (container: HTMLElement, input: ConformanceInput): ConformanceHandle
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>
  ariaLabel: string
  interactiveTooltip: true | ChartTooltipOptions<TDatum>
  mount: ConformanceMount
}

export function tanstackCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  return tanstackMount(
    createDefinition,
    ariaLabel,
    interactiveTooltip,
    previewOptions,
  )
}

export function withConformanceBehavior<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  input: ConformanceInput,
  interactiveTooltip: true | ChartTooltipOptions<TDatum>,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  const presentation =
    input.preview === true
      ? catalogPreviewDefinition(definition, previewOptions)
      : definition
  const behavior: ChartDefinitionOptions<TDatum, TXValue, TYValue, 'dom'> = {
    svgAnimation: false,
    ...(input.interactive === true ||
    (input.preview === true && previewOptions.focus)
      ? {}
      : { focus: false }),
    keyboard: input.interactive === true,
    tooltip:
      input.interactive !== true
        ? false
        : interactiveTooltip === true
          ? tooltip
          : { use: tooltip, ...interactiveTooltip },
  }

  if (isResponsiveChartDefinition(presentation)) {
    return defineChart(presentation, behavior)
  }
  return defineChart(presentation, behavior)
}

function applyCatalogPreviewFocus<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  host: ReturnType<typeof mountChart<TDatum, TXValue, TYValue>>,
  input: ConformanceInput,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue>,
) {
  if (input.preview !== true || !options.focus) return
  host.interaction.setControlledFocus(options.focus(host.getScene(), input), {
    source: 'programmatic',
  })
}
shared/preview.ts144 lines · dependency
shared/preview.ts
import { isResponsiveChartDefinition } from '@tanstack/charts'
import type {
  ChartPoint,
  ChartScene,
  ChartValue,
  DomChartDefinition,
} from '@tanstack/charts'
import type { ConformanceInput } from '../types'

export interface CatalogPreviewOptions<
  TDatum = unknown,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  /** Keep the source definition's Cartesian axes and grid. */
  guides?: boolean
  /** Keep the source definition's color legend. */
  legend?: boolean
  /** Keep the source definition's authored or automatic margins. */
  margin?: boolean
  /** Paint one deterministic source point through the chart's focus strategy. */
  focus?: (
    scene: ChartScene<TDatum, TXValue, TYValue>,
    input: ConformanceInput,
  ) => ChartPoint<TDatum, TXValue, TYValue> | null
}

export function catalogPreviewDefinition<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  if (isResponsiveChartDefinition(definition)) {
    return {
      ...definition,
      chart(context) {
        const spec = definition.chart(context)
        const color = previewColor(spec.color, options.legend === true)
        return {
          ...spec,
          ...(options.guides === true ? {} : { guides: false }),
          ...(options.margin === true ? {} : { margin: 0 }),
          ...(color ? { color } : {}),
        }
      },
    }
  }

  const color = previewColor(definition.color, options.legend === true)
  return {
    ...definition,
    ...(options.guides === true ? {} : { guides: false }),
    ...(options.margin === true ? {} : { margin: 0 }),
    ...(color ? { color } : {}),
  }
}

function previewColor<TColor extends { legend?: unknown }>(
  color: TColor | undefined,
  keepLegend: boolean,
): Omit<TColor, 'legend'> | TColor | undefined {
  if (!color || keepLegend) return color
  const { legend: _legend, ...withoutLegend } = color
  return withoutLegend
}

export function samplePreviewData<TDatum>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limit: number,
  accessors: readonly ((datum: TDatum) => number | null | undefined)[] = [],
): readonly TDatum[] {
  if (input.preview !== true || data.length <= limit) return data

  const selected = new Set<number>()
  const slots = Math.max(2, limit - accessors.length * 2)
  for (let slot = 0; slot < slots; slot += 1) {
    selected.add(Math.round((slot / (slots - 1)) * (data.length - 1)))
  }

  for (const accessor of accessors) {
    let minimumIndex = -1
    let minimum = Number.POSITIVE_INFINITY
    let maximumIndex = -1
    let maximum = Number.NEGATIVE_INFINITY

    data.forEach((datum, index) => {
      const value = accessor(datum)
      if (value === null || value === undefined || !Number.isFinite(value)) {
        return
      }
      if (value < minimum) {
        minimum = value
        minimumIndex = index
      }
      if (value > maximum) {
        maximum = value
        maximumIndex = index
      }
    })

    if (minimumIndex >= 0) selected.add(minimumIndex)
    if (maximumIndex >= 0) selected.add(maximumIndex)
  }

  return data.filter((_datum, index) => selected.has(index))
}

export function samplePreviewSeries<TDatum, TSeries>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limitPerSeries: number,
  series: (datum: TDatum) => TSeries,
): readonly TDatum[] {
  if (input.preview !== true) return data

  const indicesBySeries = new Map<TSeries, number[]>()
  data.forEach((datum, index) => {
    const key = series(datum)
    const indices = indicesBySeries.get(key) ?? []
    indices.push(index)
    indicesBySeries.set(key, indices)
  })

  const selected = new Set<number>()
  for (const indices of indicesBySeries.values()) {
    if (indices.length <= limitPerSeries) {
      indices.forEach((index) => selected.add(index))
      continue
    }
    for (let slot = 0; slot < limitPerSeries; slot += 1) {
      const index =
        indices[
          Math.round((slot / (limitPerSeries - 1)) * (indices.length - 1))
        ]
      if (index !== undefined) selected.add(index)
    }
  }

  return data.filter((_datum, index) => selected.has(index))
}
shared/react-mount.ts57 lines · dependency
shared/react-mount.ts
import { createElement } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
import type {
  ConformanceInput,
  ConformanceMount,
  ConformanceTestDriver,
} from '../types'

export interface ReactConformanceProps {
  input: ConformanceInput
  idPrefix?: string
}

export type ReactConformanceComponent = ForwardRefExoticComponent<
  ReactConformanceProps & RefAttributes<ConformanceTestDriver>
>

export function reactMount(
  Component: ReactConformanceComponent,
): ConformanceMount {
  return (container, input) => {
    const root = createRoot(container)
    let activeDriver: ConformanceTestDriver | null = null
    const driver = new Proxy({} as ConformanceTestDriver, {
      get(_target, property) {
        const value = activeDriver?.[property as keyof ConformanceTestDriver]
        return typeof value === 'function' ? value.bind(activeDriver) : value
      },
    })
    const render = (nextInput: ConformanceInput) => {
      flushSync(() => {
        root.render(
          createElement(Component, {
            input: nextInput,
            ref: (nextDriver: ConformanceTestDriver | null) => {
              activeDriver = nextDriver
            },
          }),
        )
      })
    }

    render(input)

    return {
      update: render,
      driver,
      destroy() {
        flushSync(() => {
          root.unmount()
        })
      },
    }
  }
}
types.ts376 lines · dependency
types.ts
export type ConformanceReferenceRenderer =
  'observable-plot' | 'recharts' | 'echarts'

export type ConformanceRenderer = ConformanceReferenceRenderer | 'tanstack'

export type ConformanceSupport = 'native' | 'composed' | 'gap' | 'deferred'

export type ConformanceGeometryRole =
  | 'arc'
  | 'area'
  | 'arrow'
  | 'bar'
  | 'cell'
  | 'contour'
  | 'delaunay'
  | 'density'
  | 'dot'
  | 'frame'
  | 'geo'
  | 'hexagon'
  | 'line'
  | 'link'
  | 'rect'
  | 'radar'
  | 'regression'
  | 'rule'
  | 'text'
  | 'tick'
  | 'vector'
  | 'voronoi'
  | 'waffle'

export interface ConformanceInput {
  width: number
  height: number
  revision: number
  interactive?: boolean
  /** Use lower-detail geometry suited to compact catalog cards. */
  preview?: boolean
  /** True only for semantic browser scenarios, not catalog or visual mounts. */
  behavior?: boolean
}

export interface ConformanceHandle {
  update: (input: ConformanceInput) => void
  driver?: ConformanceTestDriver
  destroy: () => void
}

export type ConformanceMount = (
  container: HTMLElement,
  input: ConformanceInput,
) => ConformanceHandle

export interface ConformanceGeometryExpectation {
  id?: string
  view?: string
  role: ConformanceGeometryRole
  count: number
  maxCount?: number
  rendererRoles?: Partial<Record<ConformanceRenderer, ConformanceGeometryRole>>
}

export type ConformanceAxis = 'x' | 'y' | 'fx' | 'fy'

export interface ConformanceGuideExpectation {
  id: string
  axis:
    | ConformanceAxis
    | (Record<'tanstack', ConformanceAxis> &
        Partial<Record<ConformanceReferenceRenderer, ConformanceAxis>>)
  sequence?: readonly string[]
  maxRepeat?: number
}

export type ConformanceJsonValue =
  | null
  | boolean
  | number
  | string
  | readonly ConformanceJsonValue[]
  | ConformanceJsonObject

export interface ConformanceJsonObject {
  readonly [key: string]: ConformanceJsonValue
}

export interface ConformanceTarget {
  view?: string
  anchor: string
}

export type ConformanceRenderedTarget =
  | {
      selector: string
      index?: number
      role?: never
      name?: never
      exact?: never
      root?: never
      page?: never
    }
  | {
      role: string
      name?: string
      exact?: boolean
      index?: number
      selector?: never
      root?: never
      page?: never
    }
  | {
      root: true
      selector?: never
      role?: never
      name?: never
      exact?: never
      index?: never
      page?: never
    }
  | {
      page: true
      selector?: never
      role?: never
      name?: never
      exact?: never
      index?: never
      root?: never
    }

export interface ConformanceResolvedTarget {
  /** Viewport-relative client coordinate used by Playwright mouse input. */
  x: number
  /** Viewport-relative client coordinate used by Playwright mouse input. */
  y: number
  /** Optional element to focus before a real Playwright keyboard action. */
  focusElement?: HTMLElement | SVGElement
}

export interface ConformanceGeometryQuery {
  view?: string
  role: ConformanceGeometryRole
}

export interface ConformanceGeometrySample {
  /** Viewport-relative client box, matching getBoundingClientRect coordinates. */
  x: number
  y: number
  width: number
  height: number
  paint?: string
}

export interface ConformanceTestDriver {
  /**
   * Benchmark-only semantic bridge. Case metadata names anchors; each renderer
   * resolves those anchors without exposing renderer-specific selectors.
   */
  resolveTarget: (target: ConformanceTarget) => ConformanceResolvedTarget | null
  readState: () => ConformanceJsonObject
  geometry?: (
    query: ConformanceGeometryQuery,
  ) => readonly ConformanceGeometrySample[]
  /**
   * Viewport-relative logical view bounds. Multi-grid renderers may expose
   * independent views without separate DOM roots.
   */
  viewBounds?: (view?: string) => ConformanceGeometrySample | null
  settle?: () => void | Promise<void>
}

export type ConformanceStateAssertion =
  | {
      path: string
      equals: ConformanceJsonValue
    }
  | {
      path: string
      includes: ConformanceJsonValue
    }
  | {
      path: string
      approx: number
      tolerance: number
    }

type ConformanceRenderedStringMatcher =
  | {
      equals: string | null
      includes?: never
    }
  | {
      includes: string
      equals?: never
    }

type ConformanceRenderedNumberMatcher =
  | {
      equals: number
      approx?: never
      tolerance?: never
      atLeast?: never
      atMost?: never
    }
  | {
      approx: number
      tolerance: number
      equals?: never
      atLeast?: never
      atMost?: never
    }
  | {
      atLeast: number
      equals?: never
      approx?: never
      tolerance?: never
      atMost?: never
    }
  | {
      atMost: number
      equals?: never
      approx?: never
      tolerance?: never
      atLeast?: never
    }

export type ConformanceRenderedAssertion =
  | ({
      target: ConformanceRenderedTarget
      property: 'count'
    } & ConformanceRenderedNumberMatcher)
  | ({
      target: ConformanceRenderedTarget
      property: 'text'
    } & ConformanceRenderedStringMatcher)
  | ({
      target: ConformanceRenderedTarget
      property: 'attribute'
      attribute: string
    } & ConformanceRenderedStringMatcher)
  | {
      target: ConformanceRenderedTarget
      property: 'visible' | 'focused'
      equals: boolean
    }
  | ({
      target: ConformanceRenderedTarget
      property:
        | 'scrollLeft'
        | 'scrollTop'
        | 'scrollWidth'
        | 'scrollHeight'
        | 'clientWidth'
        | 'clientHeight'
        | 'width'
        | 'height'
    } & ConformanceRenderedNumberMatcher)
  | {
      target: ConformanceRenderedTarget
      property: 'contained'
      within?: ConformanceRenderedTarget
      tolerance?: number
      equals: true
    }

export type ConformanceInteractionStep =
  | {
      type: 'pointerMove'
      target: ConformanceTarget
      steps?: number
    }
  | {
      type: 'pointerDown'
      target: ConformanceTarget
    }
  | {
      type: 'pointerUp'
      target: ConformanceTarget
    }
  | {
      type: 'pointerCancel'
    }
  | {
      type: 'pointerLeave'
      view?: string
    }
  | {
      type: 'update'
      revision: number
    }
  | {
      type: 'click'
      target: ConformanceTarget
    }
  | {
      type: 'key'
      key: string
      target?: ConformanceTarget
    }
  | {
      type: 'drag'
      from: ConformanceTarget
      to: ConformanceTarget
      steps?: number
    }
  | {
      type: 'wheel'
      target: ConformanceTarget
      deltaX?: number
      deltaY?: number
      steps?: number
      deltaMode?: 'pixel' | 'line' | 'page'
    }
  | {
      type: 'touchTap'
      target: ConformanceTarget
    }
  | {
      type: 'touchDrag'
      from: ConformanceTarget
      to: ConformanceTarget
      steps?: number
      cancel?: boolean
    }
  | {
      type: 'wait'
      durationMs: number
    }
  | {
      type: 'assert'
      assertions: readonly ConformanceStateAssertion[]
    }
  | {
      type: 'assertRendered'
      assertions: readonly ConformanceRenderedAssertion[]
    }
  | {
      type: 'screenshot'
      name: string
      view?: string
    }

export interface ConformanceInteractionScenario {
  id: string
  steps: readonly ConformanceInteractionStep[]
}

export interface ConformanceCaseMeta {
  schemaVersion: 1
  referenceRenderer?: ConformanceReferenceRenderer
  order: number
  id: string
  title: string
  family: string
  intent: string
  support: ConformanceSupport
  features: readonly string[]
  geometry: readonly ConformanceGeometryExpectation[]
  minimumGeometrySimilarity?: number
  guideAssertions?: readonly ConformanceGuideExpectation[]
  interactionScenarios?: readonly ConformanceInteractionScenario[]
  source: {
    title: string
    url: string
  }
  ai: {
    create: string
    maintain: string
  }
}

export interface ConformanceImplementationModule {
  mount: ConformanceMount
  /** Definition-only mount used by compact generated catalog previews. */
  catalogCase?: { mount: ConformanceMount }
}