410 lines · 2 files · 11.4 kB
cases/91-timeline-playback-scrubber/example.tsx374 lines · entry
cases/91-timeline-playback-scrubber/example.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Chart } from '@tanstack/charts/react'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { ChartScene } from '@tanstack/charts'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
export interface PlaybackState {
frame: Date
dragging: boolean
scrubCount: number
playing: boolean
}
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 {
initialPlaybackIndex,
playbackDateKey,
selectPlaybackRows,
} from './model'
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
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
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 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())
}
export default function PlaybackExample({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '91-timeline-playback-scrubber'
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)
},
[],
)
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>
)
}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
}