TanStack
TanStack source
830 chart lines · 3 files · 23.8 kB
Apple daily stock prices1,260 records · CSV · 92.4 kB
@charts-poc/demo-data/aaplSelection: Complete published snapshot
- Date
- Date
- Open
- number
- High
- number
- Low
- number
- Close
- number
- Adj Close
- number
- Volume
- number
Yahoo! Finance@observablehq/sample-datasets@1.0.1 · revision 732c0148de74 · aapl.csv · ISC distribution; upstream source credited · SHA-256 18dc8bf6542dPinned snapshot
cases/91-timeline-playback-scrubber/tanstack.ts546 lines · entry
cases/91-timeline-playback-scrubber/tanstack.ts
import { defineChart, dot, lineY, mountChart } from '@tanstack/charts'
import { focusDisabled } from '@tanstack/charts/focus/disabled'
import { aapl } from '@charts-poc/demo-data/aapl'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
initialPlaybackIndex,
playbackDateKey,
playbackIndexFromAnchor,
selectPlaybackRows,
} from './model'
import { createPlaybackOverlay } from './overlay'
import type { ChartHost, ChartScene, ChartHostOptions } from '@tanstack/charts'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
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 margin = { top: 64, right: 24, bottom: 68, left: 56 }
const playbackRows = selectPlaybackRows(aapl)
const definition = (input: ConformanceInput) => {
const rows = playbackRows
return defineChart({
marks: [
lineY(rows, {
x: 'Date',
y: 'Close',
stroke: linePaint,
strokeWidth: 2.5,
}),
dot(rows, {
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 ($)' },
},
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 = (): ChartHostOptions<AaplRow> => ({
definition: defineChart(definition(currentInput), {
animate: false,
keyboard: false,
focus: focusDisabled,
}),
width: currentInput.width,
height: currentInput.height,
ariaLabel: 'AAPL closes with a draggable timeline playback scrubber',
})
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<AaplRow>,
) {
let playbackTimer: ReturnType<typeof setInterval> | null = null
let paint = () => {}
let announce = (_message: string) => {}
const frameText = () => {
const row = playbackRows[state.index]
return row ? playbackValueText(row) : '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 = playbackRows.length - 1
const restarting = state.index >= lastIndex
if (restarting) state.index = 0
state.playing = true
playbackTimer = setInterval(() => {
const nextLastIndex = playbackRows.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(playbackRows.length - 1, index))
paint()
},
togglePlayback,
)
announce = overlay.announce
const layout = () =>
playbackLayout(chartSurface, view, host.getScene(), state.index)
paint = () => {
const nextLayout = layout()
const row = playbackRows[state.index]
if (!nextLayout || !row) return
overlay.paint(nextLayout, {
index: state.index,
max: playbackRows.length - 1,
playing: state.playing,
valueText: playbackValueText(row),
})
}
let scrubPointerId: number | null = null
const updateFromPointer = (event: PointerEvent) => {
const currentLayout = layout()
if (!currentLayout || !isScrubberTarget(view, currentLayout, event)) return
const bounds = view.getBoundingClientRect()
state.index = nearestFrameIndex(
currentLayout.frameXs,
event.clientX - bounds.left,
)
paint()
}
const handlePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
event.preventDefault()
overlay.range.focus()
beginChange()
scrubPointerId = event.pointerId
overlay.range.setPointerCapture(event.pointerId)
updateFromPointer(event)
}
const handlePointerMove = (event: PointerEvent) => {
if (scrubPointerId !== event.pointerId) return
event.preventDefault()
updateFromPointer(event)
}
const handlePointerUp = (event: PointerEvent) => {
if (scrubPointerId !== event.pointerId) return
updateFromPointer(event)
if (overlay.range.hasPointerCapture(event.pointerId)) {
overlay.range.releasePointerCapture(event.pointerId)
}
scrubPointerId = null
commitChange()
}
const commitChange = () => {
if (!state.dragging) return
state.dragging = false
state.originIndex = null
state.scrubCount += 1
paint()
announce(`Frame selected. ${frameText()}`)
}
const cancelChange = (event?: PointerEvent) => {
if (!state.dragging) return
if (event && overlay.range.hasPointerCapture(event.pointerId)) {
overlay.range.releasePointerCapture(event.pointerId)
}
scrubPointerId = null
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('pointermove', handlePointerMove)
overlay.range.addEventListener('pointerup', handlePointerUp)
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('pointermove', handlePointerMove)
overlay.range.removeEventListener('pointerup', handlePointerUp)
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(playbackRows, 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 = playbackRows
const row = rows[state.index]
return {
playhead: {
index: state.index,
date: row ? playbackDateKey(row.Date) : null,
value: row?.Close ?? null,
progress: rows.length > 1 ? state.index / (rows.length - 1) : 0,
},
frames: {
count: rows.length,
ids: rows.map((datum) => playbackDateKey(datum.Date)),
jan5Close: rows[3]?.Close ?? null,
},
interaction: {
dragging: state.dragging,
scrubCount: state.scrubCount,
playing: state.playing,
},
}
}
function playbackGeometry(
chartSurface: HTMLDivElement,
view: HTMLDivElement,
scene: ChartScene<AaplRow>,
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 = playbackRows.flatMap((row) => {
const point = sceneLocalPoint(
chartSurface,
view,
scene,
scene.scales.x.map(row.Date),
scene.scales.y.map(row.Close),
)
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<AaplRow>,
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 = playbackRows.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 !== playbackRows.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<AaplRow>,
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 isScrubberTarget(
view: HTMLDivElement,
layout: PlaybackOverlayLayout,
event: PointerEvent,
) {
const bounds = view.getBoundingClientRect()
const x = event.clientX - bounds.left
const y = event.clientY - bounds.top
return (
x >= layout.left - 12 &&
x <= layout.right + 12 &&
Math.abs(y - layout.trackY) <= 18
)
}
function nearestFrameIndex(frameXs: readonly number[], x: number) {
let nearestIndex = 0
let nearestDistance = Number.POSITIVE_INFINITY
frameXs.forEach((frameX, index) => {
const distance = Math.abs(frameX - x)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
})
return nearestIndex
}
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`
}
function playbackValueText(row: AaplRow) {
return `${playbackDateKey(row.Date)} · AAPL close $${row.Close.toFixed(2)}`
}cases/91-timeline-playback-scrubber/model.ts36 lines · support
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/overlay.ts248 lines · support
cases/91-timeline-playback-scrubber/overlay.ts
export interface PlaybackOverlayLayout {
left: number
right: number
top: number
bottom: number
trackY: number
playheadX: number
frameXs: readonly number[]
}
export interface PlaybackOverlayState {
index: number
max: number
playing: boolean
valueText: string
}
export function createPlaybackOverlay(
view: HTMLDivElement,
onInput: (index: number) => void,
onTogglePlayback: () => void,
) {
const document = view.ownerDocument
const style = document.createElement('style')
style.textContent = `
.ts-conformance-playback-range {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
color-scheme: light dark;
cursor: ew-resize;
margin: 0;
padding: 0;
}
.ts-conformance-playback-range::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
.ts-conformance-playback-range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -8px;
border: 0;
background: transparent;
}
.ts-conformance-playback-range::-moz-range-track {
height: 4px;
background: transparent;
}
.ts-conformance-playback-range::-moz-range-thumb {
width: 20px;
height: 20px;
border: 0;
background: transparent;
}
.ts-conformance-playback-range:focus-visible {
outline: none;
}
.ts-conformance-playback-range:focus-visible
+ .ts-conformance-playback-handle {
outline: 3px solid var(--ts-chart-1, #2563eb);
outline-offset: 3px;
}
.ts-conformance-playback-toolbar {
color: inherit;
}
.ts-conformance-playback-current,
.ts-conformance-playback-button {
border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
background: color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas);
color: inherit;
}
.ts-conformance-playback-button:hover {
background: color-mix(in srgb, var(--ts-chart-2, #f97316) 20%, Canvas);
}
.ts-conformance-playback-button:focus-visible {
outline: 3px solid var(--ts-chart-1, #2563eb);
outline-offset: 2px;
}
`
const layer = document.createElement('div')
layer.style.position = 'absolute'
layer.style.inset = '0'
layer.style.zIndex = '3'
layer.style.pointerEvents = 'none'
const track = document.createElement('div')
track.className = 'ts-conformance-playback-track'
track.dataset.conformancePlaybackRule = 'track'
track.style.position = 'absolute'
track.style.height = '4px'
track.style.borderRadius = '999px'
track.style.background = 'color-mix(in srgb, currentColor 52%, transparent)'
const playhead = document.createElement('div')
playhead.className = 'ts-conformance-playback-playhead'
playhead.dataset.conformancePlaybackRule = 'playhead'
playhead.style.position = 'absolute'
playhead.style.width = '2px'
playhead.style.borderRadius = '999px'
playhead.style.background = 'var(--ts-chart-2, #f97316)'
const range = document.createElement('input')
range.className = 'ts-conformance-playback-range'
range.type = 'range'
range.min = '0'
range.step = '1'
range.setAttribute('aria-label', 'Timeline frame')
range.style.position = 'absolute'
range.style.height = '44px'
range.style.pointerEvents = 'auto'
const handle = document.createElement('div')
handle.className = 'ts-conformance-playback-handle'
handle.dataset.conformancePlaybackHandle = ''
handle.style.position = 'absolute'
handle.style.boxSizing = 'border-box'
handle.style.width = '20px'
handle.style.height = '20px'
handle.style.border = '2px solid Canvas'
handle.style.borderRadius = '999px'
handle.style.background = 'var(--ts-chart-2, #f97316)'
handle.style.boxShadow = '0 1px 4px rgb(15 23 42 / 0.35)'
const toolbar = document.createElement('div')
toolbar.className = 'ts-conformance-playback-toolbar'
toolbar.setAttribute('role', 'group')
toolbar.setAttribute('aria-label', 'Timeline playback controls')
toolbar.style.position = 'absolute'
toolbar.style.top = '4px'
toolbar.style.left = '56px'
toolbar.style.right = '20px'
toolbar.style.display = 'flex'
toolbar.style.alignItems = 'center'
toolbar.style.justifyContent = 'flex-end'
toolbar.style.gap = '8px'
toolbar.style.pointerEvents = 'none'
const current = document.createElement('div')
current.className = 'ts-conformance-playback-current'
current.style.boxSizing = 'border-box'
current.style.minWidth = '0'
current.style.minHeight = '32px'
current.style.padding = '7px 9px'
current.style.borderRadius = '999px'
current.style.overflow = 'hidden'
current.style.textOverflow = 'ellipsis'
current.style.whiteSpace = 'nowrap'
current.style.font = '600 12px/1.2 system-ui, sans-serif'
const playButton = document.createElement('button')
playButton.className = 'ts-conformance-playback-button'
playButton.type = 'button'
playButton.setAttribute('aria-label', 'Play timeline')
playButton.title = 'Play timeline'
playButton.style.flex = '0 0 auto'
playButton.style.width = '44px'
playButton.style.height = '44px'
playButton.style.borderRadius = '10px'
playButton.style.cursor = 'pointer'
playButton.style.font = '700 16px/1 system-ui, sans-serif'
playButton.style.pointerEvents = 'auto'
const status = document.createElement('output')
status.className = 'ts-conformance-playback-announcement'
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
status.setAttribute('aria-atomic', 'true')
status.style.position = 'absolute'
status.style.width = '1px'
status.style.height = '1px'
status.style.padding = '0'
status.style.margin = '-1px'
status.style.overflow = 'hidden'
status.style.clipPath = 'inset(50%)'
status.style.whiteSpace = 'nowrap'
const handleInput = () => {
onInput(Number(range.value))
}
range.addEventListener('input', handleInput)
playButton.addEventListener('click', onTogglePlayback)
toolbar.append(current, playButton)
layer.append(track, playhead, range, handle, toolbar, status)
view.append(style, layer)
return {
range,
playButton,
announce(message: string) {
status.value = message
status.textContent = message
},
ruleGeometry() {
return [measureElement(track), measureElement(playhead)]
},
paint(layout: PlaybackOverlayLayout, state: PlaybackOverlayState) {
track.style.left = `${layout.left}px`
track.style.top = `${layout.trackY - 2}px`
track.style.width = `${Math.max(0, layout.right - layout.left)}px`
playhead.style.left = `${layout.playheadX - 1}px`
playhead.style.top = `${layout.top}px`
playhead.style.height = `${Math.max(0, layout.trackY - layout.top)}px`
range.max = String(state.max)
range.value = String(state.index)
range.setAttribute('aria-valuetext', state.valueText)
range.style.left = `${layout.left - 10}px`
range.style.top = `${layout.trackY - 22}px`
range.style.width = `${Math.max(0, layout.right - layout.left + 20)}px`
handle.style.left = `${layout.playheadX - 10}px`
handle.style.top = `${layout.trackY - 10}px`
current.textContent = state.valueText
playButton.textContent = state.playing ? '❚❚' : '▶'
playButton.setAttribute('aria-pressed', String(state.playing))
playButton.setAttribute(
'aria-label',
state.playing ? 'Pause timeline' : 'Play timeline',
)
playButton.title = state.playing ? 'Pause timeline' : 'Play timeline'
},
destroy() {
range.removeEventListener('input', handleInput)
playButton.removeEventListener('click', onTogglePlayback)
style.remove()
layer.remove()
},
}
}
function measureElement(element: HTMLElement) {
const bounds = element.getBoundingClientRect()
return {
x: bounds.left,
y: bounds.top,
width: bounds.width,
height: bounds.height,
paint: getComputedStyle(element).backgroundColor,
}
}