Source
714 chart lines · 2 files · 21.0 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/90-zoomable-time-window/tanstack.ts611 lines · entry
cases/90-zoomable-time-window/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 { select } from 'd3-selection'
import { zoom, zoomIdentity } from 'd3-zoom'
import {
initialZoomWindow,
selectZoomRows,
visibleZoomData,
zoomDateFromAnchor,
zoomDateKey,
zoomFullDomain,
zoomSpanDays,
} from './model'
import type { ChartScene, ChartHostOptions } from '@tanstack/charts'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { D3ZoomEvent, ZoomTransform } from 'd3-zoom'
import type { ZoomWindow } from './model'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceHandle,
ConformanceInput,
ConformanceJsonObject,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
interface ZoomChartInput extends ConformanceInput {
window: ZoomWindow
}
interface ZoomState {
window: ZoomWindow
lastAction: 'none' | 'zoom' | 'pan' | 'reset'
active: boolean
wheelCaptured: boolean
}
const color = '#0f766e'
const zoomScale = scaleUtc().domain(zoomFullDomain)
const zoomRows = selectZoomRows(aapl)
const definition = (input: ZoomChartInput) => {
const rows = visibleZoomData(zoomRows, input.window)
return defineChart({
marks: [
lineY(rows, {
x: 'Date',
y: 'Close',
stroke: color,
strokeWidth: 2.5,
}),
dot(rows, {
x: 'Date',
y: 'Close',
fill: color,
r: 3.5,
stroke: '#ffffff',
strokeWidth: 1,
}),
],
x: {
scale: scaleForWindow(input.window),
axis: {
ticks: {
format: (value) =>
value.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}),
},
label: 'Date',
},
},
y: {
scale: scaleLinear,
grid: true,
axis: { ticks: { count: 4 }, label: 'AAPL close ($)' },
},
margin: { top: 56, right: 24, bottom: 44, left: 58 },
})
}
export function mount(
container: HTMLElement,
input: ConformanceInput,
): ConformanceHandle {
let currentInput = input
const state: ZoomState = {
window: { ...initialZoomWindow },
lastAction: 'none',
active: false,
wheelCaptured: false,
}
const surface = container.ownerDocument.createElement('div')
surface.dataset.conformanceView = 'main'
surface.style.position = 'relative'
setSurfaceSize(surface, input)
container.append(surface)
const options = (): ChartHostOptions<AaplRow> => ({
definition: defineChart(
definition({
...currentInput,
window: state.window,
}),
{ animate: false, keyboard: false, focus: focusDisabled },
),
width: currentInput.width,
height: currentInput.height,
ariaLabel: 'Time series with a wheel-zoomable and pannable time viewport',
})
const host = mountChart(surface, options())
const controller = createZoomController(
surface,
() => host.getScene(),
state,
() => host.update(options()),
)
controller.sync()
const driver = createDriver(
surface,
() => currentInput,
() => host.getScene(),
state,
)
return {
driver,
update(nextInput) {
currentInput = nextInput
setSurfaceSize(surface, nextInput)
host.update(options())
controller.sync()
},
destroy() {
controller.destroy()
host.destroy()
surface.remove()
},
}
}
function scaleForWindow(window: ZoomWindow) {
return zoomScale.copy().domain([window.start, window.end])
}
function createZoomController(
surface: HTMLDivElement,
getScene: () => ChartScene<AaplRow>,
state: ZoomState,
render: () => void,
) {
const document = surface.ownerDocument
const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
overlay.dataset.conformanceOverlay = 'zoom'
Object.assign(overlay.style, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
overflow: 'hidden',
pointerEvents: 'none',
})
const interaction = document.createElementNS(
'http://www.w3.org/2000/svg',
'rect',
)
interaction.dataset.conformanceZoomSurface = 'true'
interaction.setAttribute('fill', 'transparent')
interaction.setAttribute('tabindex', '0')
interaction.setAttribute('role', 'application')
interaction.setAttribute(
'aria-label',
'Zoomable time window. Focus the chart before wheel zoom; drag or use a horizontal wheel to pan; use plus, minus, arrow keys, or Home.',
)
interaction.setAttribute('aria-keyshortcuts', 'ArrowLeft ArrowRight + - Home')
interaction.style.pointerEvents = 'all'
interaction.style.touchAction = 'pan-y'
interaction.setAttribute('stroke', 'transparent')
interaction.setAttribute('stroke-width', '3')
interaction.setAttribute('stroke-dasharray', '6 4')
interaction.setAttribute('vector-effect', 'non-scaling-stroke')
overlay.append(interaction)
const status = document.createElement('output')
status.dataset.conformanceZoomStatus = 'true'
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
Object.assign(status.style, {
position: 'absolute',
top: '10px',
right: '76px',
zIndex: '4',
padding: '4px 8px',
border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)',
borderRadius: '999px',
background: 'Canvas',
color: 'CanvasText',
font: '600 12px/1.2 system-ui, sans-serif',
pointerEvents: 'none',
})
const reset = document.createElement('button')
reset.dataset.conformanceZoomReset = 'true'
reset.type = 'button'
reset.textContent = '↺'
reset.title = 'Reset zoom'
reset.setAttribute('aria-label', 'Reset zoom')
Object.assign(reset.style, {
position: 'absolute',
top: '6px',
right: '20px',
zIndex: '4',
width: '44px',
height: '44px',
border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)',
borderRadius: '10px',
background: 'Canvas',
color: 'CanvasText',
cursor: 'pointer',
font: '700 20px/1 system-ui, sans-serif',
})
surface.append(overlay, status, reset)
const interactionSelection = select<SVGRectElement, unknown>(interaction)
let syncing = false
let pendingAction: ZoomState['lastAction'] | null = null
const behavior = zoom<SVGRectElement, unknown>()
.touchable(true)
.scaleExtent([1, 8])
.clickDistance(3)
.filter((event) => {
if (!state.active) return false
if (event.type !== 'wheel') return !event.button
const capturesWheel =
Math.abs(event.deltaY) >= Math.abs(event.deltaX) && event.deltaY !== 0
if (capturesWheel) state.wheelCaptured = true
return capturesWheel
})
.wheelDelta((event) => -normalizedWheelDelta(event, 'y') / 240)
.on('zoom', (event: D3ZoomEvent<SVGRectElement, unknown>) => {
if (syncing) return
const scene = getScene()
state.window = windowFromTransform(scene, event.transform)
state.lastAction =
pendingAction ??
(event.sourceEvent instanceof WheelEvent ? 'zoom' : 'pan')
pendingAction = null
render()
updateStatus()
})
const updateStatus = () => {
const label = state.active
? `${zoomDateKey(state.window.start)} → ${zoomDateKey(state.window.end)} · ${formatSpan(zoomSpanDays(state.window))} days`
: 'Focus chart to zoom'
status.value = label
status.textContent = label
interaction.setAttribute(
'aria-description',
`${label}. Wheel zoom; drag or horizontal wheel pan; plus and minus zoom; arrows pan; Home resets.`,
)
}
const updateActivation = (active: boolean) => {
state.active = active
interaction.style.touchAction = active ? 'none' : 'pan-y'
interaction.setAttribute('stroke', active ? 'currentColor' : 'transparent')
interaction.dataset.zoomActive = String(active)
updateStatus()
}
const sync = () => {
const scene = getScene()
overlay.setAttribute('viewBox', `0 0 ${scene.width} ${scene.height}`)
interaction.setAttribute('x', String(scene.chart.x))
interaction.setAttribute('y', String(scene.chart.y))
interaction.setAttribute('width', String(scene.chart.width))
interaction.setAttribute('height', String(scene.chart.height))
behavior
.extent([
[scene.chart.x, scene.chart.y],
[scene.chart.x + scene.chart.width, scene.chart.y + scene.chart.height],
])
.translateExtent([
[scene.chart.x, scene.chart.y],
[scene.chart.x + scene.chart.width, scene.chart.y + scene.chart.height],
])
syncing = true
interactionSelection.call(behavior)
interactionSelection.call(
behavior.transform,
transformForWindow(scene, state.window),
)
syncing = false
updateActivation(state.active)
}
const handlePointerDown = () => {
interaction.focus()
}
const handleFocus = () => {
updateActivation(true)
}
const handleBlur = () => {
updateActivation(false)
}
const observeWheel = () => {
state.wheelCaptured = false
}
const handleHorizontalWheel = (event: WheelEvent) => {
if (
document.activeElement !== interaction ||
Math.abs(event.deltaX) <= Math.abs(event.deltaY) ||
!event.deltaX
) {
return
}
event.preventDefault()
state.wheelCaptured = true
const scene = getScene()
const fraction = normalizedWheelDelta(event, 'x') / 880
const visibleFraction =
zoomSpanDays(state.window) / zoomSpanDays(initialZoomWindow)
const basePixels = fraction * visibleFraction * scene.chart.width
pendingAction = 'pan'
interactionSelection.call(behavior.translateBy, -basePixels, 0)
}
const handleKeyDown = (event: KeyboardEvent) => {
const scene = getScene()
const center: [number, number] = [
scene.chart.x + scene.chart.width / 2,
scene.chart.y + scene.chart.height / 2,
]
if (event.key === '+' || event.key === '=') {
event.preventDefault()
pendingAction = 'zoom'
interactionSelection.call(behavior.scaleBy, 2, center)
return
}
if (event.key === '-') {
event.preventDefault()
pendingAction = 'zoom'
interactionSelection.call(behavior.scaleBy, 0.5, center)
return
}
if (event.key === 'Home') {
event.preventDefault()
pendingAction = 'reset'
interactionSelection.call(behavior.transform, zoomIdentity)
return
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault()
const direction = event.key === 'ArrowLeft' ? 1 : -1
const visibleFraction =
zoomSpanDays(state.window) / zoomSpanDays(initialZoomWindow)
pendingAction = 'pan'
interactionSelection.call(
behavior.translateBy,
direction * visibleFraction * scene.chart.width * 0.125,
0,
)
}
}
const resetZoom = () => {
pendingAction = 'reset'
interactionSelection.call(behavior.transform, zoomIdentity)
interaction.focus()
}
interaction.addEventListener('pointerdown', handlePointerDown)
interaction.addEventListener('focus', handleFocus)
interaction.addEventListener('blur', handleBlur)
interaction.addEventListener('wheel', observeWheel, { capture: true })
interaction.addEventListener('wheel', handleHorizontalWheel, {
passive: false,
})
interaction.addEventListener('keydown', handleKeyDown)
reset.addEventListener('click', resetZoom)
return {
sync,
destroy() {
interaction.removeEventListener('pointerdown', handlePointerDown)
interaction.removeEventListener('focus', handleFocus)
interaction.removeEventListener('blur', handleBlur)
interaction.removeEventListener('wheel', observeWheel, true)
interaction.removeEventListener('wheel', handleHorizontalWheel)
interaction.removeEventListener('keydown', handleKeyDown)
reset.removeEventListener('click', resetZoom)
interactionSelection.on('.zoom', null)
overlay.remove()
status.remove()
reset.remove()
},
}
}
function transformForWindow(
scene: ChartScene<AaplRow>,
window: ZoomWindow,
): ZoomTransform {
const fullSpan = zoomSpanDays(initialZoomWindow)
const span = zoomSpanDays(window)
const scale = Math.max(1, fullSpan / span)
const baseScale = zoomScale
.copy()
.range([scene.chart.x, scene.chart.x + scene.chart.width])
const start = baseScale(window.start)
return zoomIdentity.translate(scene.chart.x - scale * start, 0).scale(scale)
}
function windowFromTransform(
scene: ChartScene<AaplRow>,
transform: ZoomTransform,
): ZoomWindow {
const baseScale = zoomScale
.copy()
.range([scene.chart.x, scene.chart.x + scene.chart.width])
const domain = transform.rescaleX(baseScale).domain()
return {
start: domain[0] ?? zoomFullDomain[0],
end: domain[1] ?? zoomFullDomain[1],
}
}
function normalizedWheelDelta(event: WheelEvent, axis: 'x' | 'y') {
const value = axis === 'x' ? event.deltaX : event.deltaY
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return value * 16
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return value * 240
}
return value
}
function formatSpan(days: number) {
return Number.isInteger(days) ? String(days) : days.toFixed(1)
}
function createDriver(
surface: HTMLDivElement,
getInput: () => ConformanceInput,
getScene: () => ChartScene<AaplRow>,
state: ZoomState,
): ConformanceTestDriver {
return {
resolveTarget(target) {
return resolveTarget(surface, getScene(), target)
},
readState() {
return interactionState(state, getInput())
},
geometry(query) {
return zoomGeometry(surface, getScene(), getInput(), state.window, query)
},
}
}
function resolveTarget(
surface: HTMLDivElement,
scene: ChartScene<AaplRow>,
target: ConformanceTarget,
) {
if (target.view !== undefined && target.view !== 'main') return null
if (target.anchor === 'control:reset') {
const reset = surface.querySelector<HTMLButtonElement>(
'[data-conformance-zoom-reset]',
)
if (!reset) return null
const bounds = reset.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: reset,
}
}
const date = zoomDateFromAnchor(zoomRows, target.anchor)
if (!date) return null
const row = zoomRows.find((datum) => datum.Date.getTime() === date.getTime())
if (!row) return null
return scenePointToClient(
surface,
scene,
scene.scales.x.map(date),
scene.scales.y.map(row.Close),
surface.querySelector<SVGRectElement>('[data-conformance-zoom-surface]') ??
undefined,
)
}
function interactionState(
state: ZoomState,
input: ConformanceInput,
): ConformanceJsonObject {
const visibleRows = visibleZoomData(zoomRows, state.window)
const jan9Row = visibleRows.find(
(row) => zoomDateKey(row.Date) === '2018-01-09',
)
return {
viewport: {
start: zoomDateKey(state.window.start),
end: zoomDateKey(state.window.end),
spanDays: zoomSpanDays(state.window),
},
visible: {
count: visibleRows.length,
ids: visibleRows.map((row) => zoomDateKey(row.Date)),
jan9Close: jan9Row?.Close ?? null,
},
interaction: {
last: state.lastAction,
active: state.active,
wheelCaptured: state.wheelCaptured,
},
}
}
function zoomGeometry(
surface: HTMLDivElement,
scene: ChartScene<AaplRow>,
input: ConformanceInput,
window: ZoomWindow,
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 = visibleZoomData(zoomRows, window).map(
(row): readonly [number, number] => [
scene.scales.x.map(row.Date),
scene.scales.y.map(row.Close),
],
)
if (query.role === 'dot') {
return points.map((point) => ({
x: bounds.left + (point[0] - 3.5) * scaleX,
y: bounds.top + (point[1] - 3.5) * scaleY,
width: 7 * scaleX,
height: 7 * scaleY,
paint: color,
}))
}
if (query.role === 'line') {
const sample = pointsBounds(points, bounds, scaleX, scaleY, color)
return sample ? [sample] : []
}
return []
}
function scenePointToClient(
surface: HTMLDivElement,
scene: ChartScene<AaplRow>,
x: number,
y: number,
focusElement?: HTMLElement | SVGElement,
) {
const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
if (!svg) return null
const bounds = svg.getBoundingClientRect()
return {
x: bounds.left + (x / scene.width) * bounds.width,
y: bounds.top + (y / scene.height) * bounds.height,
focusElement: focusElement ?? svg,
}
}
function pointsBounds(
points: readonly (readonly [number, number])[],
svgBounds: DOMRect,
scaleX: number,
scaleY: number,
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: svgBounds.left + left * scaleX,
y: svgBounds.top + top * scaleY,
width: Math.max(1, (right - left) * scaleX),
height: Math.max(1, (bottom - top) * scaleY),
paint,
}
}
function setSurfaceSize(surface: HTMLDivElement, input: ConformanceInput) {
surface.style.width = `${input.width}px`
surface.style.height = `${input.height}px`
}cases/90-zoomable-time-window/model.ts103 lines · support
cases/90-zoomable-time-window/model.ts
import type { AaplRow } from '@charts-poc/demo-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)),
}
}