Source
778 chart lines · 3 files · 23.3 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/89-brush-range-selection/tanstack.ts597 lines · entry
cases/89-brush-range-selection/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 { brushX } from 'd3-brush'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { select } from 'd3-selection'
import {
brushDomain,
brushDateFromAnchor,
brushDateKey,
brushRangeSummary,
brushShortDate,
clampBrushDate,
initialBrushRange,
monthlyAaplRows,
normalizedBrushRange,
observedBrushDates,
} from './model'
import { brushSelectionFill, normalizedElementFill } from './paint'
import type { ChartScene, ChartHostOptions } from '@tanstack/charts'
import type { D3BrushEvent } from 'd3-brush'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { BrushRange } from './model'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceHandle,
ConformanceInput,
ConformanceJsonObject,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
interface BrushState {
range: BrushRange
dragging: boolean
originRange: BrushRange | null
}
const color = '#2563eb'
const brushRows = monthlyAaplRows(aapl)
const brushDates = observedBrushDates(brushRows)
const fullDomain = brushDomain(brushDates)
const brushScale = scaleUtc().domain(fullDomain)
const brushMonthFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
timeZone: 'UTC',
})
const definition = (input: ConformanceInput) => {
const rows = brushRows
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: brushScale,
axis: {
ticks: { format: (value) => brushMonthFormatter.format(value) },
label: 'Month',
},
},
y: {
scale: scaleLinear,
grid: true,
axis: { ticks: { count: 4 }, label: 'AAPL close ($)' },
},
margin: { top: 52, right: 24, bottom: 44, left: 58 },
})
}
export function mount(
container: HTMLElement,
input: ConformanceInput,
): ConformanceHandle {
let currentInput = input
const state: BrushState = {
range: { ...initialBrushRange(brushDates) },
dragging: false,
originRange: null,
}
const surface = container.ownerDocument.createElement('div')
surface.dataset.conformanceView = 'main'
surface.style.position = 'relative'
surface.setAttribute('role', 'application')
surface.setAttribute(
'aria-label',
'Monthly time range brush with two adjustable handles',
)
setSurfaceSize(surface, input)
container.append(surface)
const options = (nextInput: ConformanceInput): ChartHostOptions<AaplRow> => ({
definition: defineChart(definition(nextInput), {
animate: false,
keyboard: false,
focus: focusDisabled,
}),
width: nextInput.width,
height: nextInput.height,
ariaLabel: 'Time series with a draggable horizontal range brush',
})
const host = mountChart(surface, options(input))
const controller = createBrushController(
surface,
() => currentInput,
() => host.getScene(),
state,
)
controller.sync()
const driver = createDriver(
surface,
() => currentInput,
() => host.getScene(),
state,
controller.sync,
)
return {
driver,
update(nextInput) {
currentInput = nextInput
setSurfaceSize(surface, nextInput)
host.update(options(nextInput))
controller.sync()
},
destroy() {
controller.destroy()
host.destroy()
surface.remove()
},
}
}
function createBrushController(
surface: HTMLDivElement,
getInput: () => ConformanceInput,
getScene: () => ChartScene<AaplRow>,
state: BrushState,
) {
const document = surface.ownerDocument
const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
overlay.dataset.conformanceOverlay = 'brush'
overlay.setAttribute('role', 'group')
overlay.setAttribute(
'aria-label',
'Monthly range brush. Drag to select; focus either handle and use arrow keys, Home, or End to adjust.',
)
Object.assign(overlay.style, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
overflow: 'hidden',
pointerEvents: 'auto',
touchAction: 'none',
})
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g')
overlay.append(group)
const status = document.createElement('output')
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
Object.assign(status.style, {
position: 'absolute',
right: '24px',
top: '10px',
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',
})
surface.append(overlay, status)
const groupSelection = select<SVGGElement, unknown>(group)
let syncing = false
const behavior = brushX<unknown>()
.touchable(true)
.keyModifiers(true)
.handleSize(16)
.on('start', (event: D3BrushEvent<unknown>) => {
if (syncing || !event.sourceEvent) return
state.originRange = { ...state.range }
state.dragging = true
updateStatus()
})
.on('brush', (event: D3BrushEvent<unknown>) => {
if (syncing || !event.sourceEvent) return
const range = rangeFromSelection(event.selection)
if (!range) return
state.range = range
updateStatus()
decorateBrush()
})
.on('end', (event: D3BrushEvent<unknown>) => {
if (syncing || !event.sourceEvent) return
const cancelled =
event.sourceEvent instanceof Event &&
event.sourceEvent.type === 'touchcancel'
if (cancelled && state.originRange) {
state.range = state.originRange
} else {
const range = rangeFromSelection(event.selection)
if (range) state.range = range
}
state.dragging = false
state.originRange = null
sync()
})
const rangeScale = () => {
const scene = getScene()
return brushScale
.copy()
.range([scene.chart.x, scene.chart.x + scene.chart.width])
}
const rangeFromSelection = (
selection: D3BrushEvent<unknown>['selection'],
): BrushRange | null => {
if (
!selection ||
Array.isArray(selection[0]) ||
typeof selection[0] !== 'number' ||
typeof selection[1] !== 'number'
) {
return null
}
const scale = rangeScale()
return normalizedBrushRange(
clampBrushDate(brushDates, scale.invert(selection[0])),
clampBrushDate(brushDates, scale.invert(selection[1])),
)
}
const updateStatus = () => {
const summary = brushRangeSummary(brushRows, state.range)
const label = `${brushShortDate(state.range.start)} → ${brushShortDate(state.range.end)} · ${summary.count} AAPL closes · avg $${summary.average.toFixed(1)}`
status.value = label
status.textContent = label
status.setAttribute(
'aria-label',
`${brushDateKey(state.range.start)} through ${brushDateKey(state.range.end)}, ${summary.count} AAPL closing prices, average $${summary.average.toFixed(1)}`,
)
}
const decorateBrush = () => {
const selection = group.querySelector<SVGRectElement>('.selection')
if (selection) {
selection.dataset.conformanceSelection = 'range'
selection.setAttribute('fill', brushSelectionFill)
selection.setAttribute('fill-opacity', '1')
selection.setAttribute('stroke', color)
selection.setAttribute('stroke-width', '1')
}
group.querySelectorAll<SVGRectElement>('.handle').forEach((handle) => {
const isStart = handle.classList.contains('handle--w')
const index = brushDates.findIndex(
(date) =>
date.getTime() ===
(isStart ? state.range.start : state.range.end).getTime(),
)
handle.setAttribute('tabindex', '0')
handle.setAttribute('role', 'slider')
handle.setAttribute('aria-label', isStart ? 'Range start' : 'Range end')
handle.setAttribute(
'aria-valuemin',
String(isStart ? 0 : Math.max(0, rangeIndex(state.range.start))),
)
handle.setAttribute(
'aria-valuemax',
String(
isStart
? Math.max(0, rangeIndex(state.range.end))
: Math.max(0, brushDates.length - 1),
),
)
handle.setAttribute('aria-valuenow', String(Math.max(0, index)))
handle.setAttribute('aria-orientation', 'horizontal')
handle.setAttribute(
'aria-keyshortcuts',
'ArrowLeft ArrowRight ArrowUp ArrowDown Home End',
)
handle.setAttribute(
'aria-valuetext',
brushDateKey(isStart ? state.range.start : state.range.end),
)
handle.setAttribute('fill', 'Canvas')
handle.setAttribute('fill-opacity', '1')
handle.setAttribute('stroke', color)
handle.setAttribute('stroke-width', '2')
handle.style.outline = 'none'
})
}
const sync = () => {
const scene = getScene()
overlay.setAttribute('viewBox', `0 0 ${scene.width} ${scene.height}`)
behavior.extent([
[scene.chart.x, scene.chart.y],
[scene.chart.x + scene.chart.width, scene.chart.y + scene.chart.height],
])
syncing = true
groupSelection.call(behavior)
groupSelection.call(behavior.move, [
scene.scales.x.map(state.range.start),
scene.scales.x.map(state.range.end),
])
syncing = false
decorateBrush()
updateStatus()
}
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target
if (!(target instanceof SVGRectElement) || !target.matches('.handle')) {
return
}
const isStart = target.classList.contains('handle--w')
const current = isStart ? state.range.start : state.range.end
const currentIndex = rangeIndex(current)
if (currentIndex < 0) return
const boundary = isStart ? state.range.end : state.range.start
const boundaryIndex = rangeIndex(boundary)
const direction =
event.key === 'ArrowLeft' || event.key === 'ArrowDown'
? -1
: event.key === 'ArrowRight' || event.key === 'ArrowUp'
? 1
: 0
const requestedIndex =
event.key === 'Home'
? isStart
? 0
: boundaryIndex
: event.key === 'End'
? isStart
? boundaryIndex
: brushDates.length - 1
: currentIndex + direction
if (!direction && event.key !== 'Home' && event.key !== 'End') return
event.preventDefault()
const nextIndex = Math.max(
isStart ? 0 : boundaryIndex,
Math.min(isStart ? boundaryIndex : brushDates.length - 1, requestedIndex),
)
const nextDate = brushDates[nextIndex]
if (!nextDate) return
state.range = isStart
? { start: nextDate, end: state.range.end }
: { start: state.range.start, end: nextDate }
sync()
const nextHandle = group.querySelector<SVGRectElement>(
isStart ? '.handle--w' : '.handle--e',
)
nextHandle?.focus()
}
const handleFocusChange = (event: FocusEvent) => {
const handle =
event.target instanceof SVGRectElement && event.target.matches('.handle')
? event.target
: null
if (!handle) return
handle.setAttribute('stroke-width', event.type === 'focusin' ? '4' : '2')
handle.setAttribute('stroke', event.type === 'focusin' ? '#1d4ed8' : color)
}
const cancelActiveBrush = () => {
if (!state.dragging || !state.originRange) return
state.range = state.originRange
state.dragging = false
state.originRange = null
sync()
}
group.addEventListener('keydown', handleKeyDown)
group.addEventListener('focusin', handleFocusChange)
group.addEventListener('focusout', handleFocusChange)
overlay.addEventListener('pointercancel', cancelActiveBrush)
overlay.addEventListener('touchcancel', cancelActiveBrush)
return {
sync,
destroy() {
group.removeEventListener('keydown', handleKeyDown)
group.removeEventListener('focusin', handleFocusChange)
group.removeEventListener('focusout', handleFocusChange)
overlay.removeEventListener('pointercancel', cancelActiveBrush)
overlay.removeEventListener('touchcancel', cancelActiveBrush)
groupSelection.on('.brush', null)
overlay.remove()
status.remove()
},
}
}
function createDriver(
surface: HTMLDivElement,
getInput: () => ConformanceInput,
getScene: () => ChartScene<AaplRow>,
state: BrushState,
settle: () => void,
): ConformanceTestDriver {
return {
resolveTarget(target) {
return resolveTarget(surface, getScene(), target)
},
readState() {
return interactionState(state, getInput())
},
geometry(query) {
return brushGeometry(surface, getScene(), getInput(), state.range, query)
},
settle,
}
}
function resolveTarget(
surface: HTMLDivElement,
scene: ChartScene<AaplRow>,
target: ConformanceTarget,
) {
if (target.view !== undefined && target.view !== 'main') return null
if (target.anchor === 'handle:start' || target.anchor === 'handle:end') {
const handle = surface.querySelector<SVGRectElement>(
target.anchor === 'handle:start' ? '.handle--w' : '.handle--e',
)
if (!handle) return null
const bounds = handle.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: handle,
}
}
const date = brushDateFromAnchor(brushDates, target.anchor)
if (!date) return null
const row = brushRows.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),
)
}
function interactionState(
state: BrushState,
input: ConformanceInput,
): ConformanceJsonObject {
const summary = brushRangeSummary(brushRows, state.range)
return {
selection: {
start: brushDateKey(state.range.start),
end: brushDateKey(state.range.end),
pointCount: summary.count,
closeAverage: summary.average,
closeChange: summary.change,
dragging: state.dragging,
},
}
}
function brushGeometry(
surface: HTMLDivElement,
scene: ChartScene<AaplRow>,
input: ConformanceInput,
range: BrushRange,
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 = brushRows.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] : []
}
if (query.role === 'rect') {
const band = surface.querySelector<SVGRectElement>(
'[data-conformance-selection="range"]',
)
if (!band) {
const start = scene.scales.x.map(range.start)
const end = scene.scales.x.map(range.end)
return [
{
x: bounds.left + Math.min(start, end) * scaleX,
y: bounds.top + scene.chart.y * scaleY,
width: Math.max(1, Math.abs(end - start) * scaleX),
height: scene.chart.height * scaleY,
paint: 'missing-tanstack-rendered-brush-fill',
},
]
}
const bandBounds = band.getBoundingClientRect()
return [
{
x: bandBounds.x,
y: bandBounds.y,
width: bandBounds.width,
height: bandBounds.height,
paint:
normalizedElementFill(band) ?? 'invalid-tanstack-rendered-brush-fill',
},
]
}
return []
}
function scenePointToClient(
surface: HTMLDivElement,
scene: ChartScene<AaplRow>,
x: number,
y: number,
) {
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: 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`
}
function rangeIndex(date: Date) {
return brushDates.findIndex(
(candidate) => candidate.getTime() === date.getTime(),
)
}cases/89-brush-range-selection/model.ts89 lines · support
cases/89-brush-range-selection/model.ts
import type { AaplRow } from '@charts-poc/demo-data/aapl'
export interface BrushRange {
start: Date
end: Date
}
export function monthlyAaplRows(rows: readonly AaplRow[]): readonly AaplRow[] {
const byMonth = new Map<number, AaplRow>()
for (const row of rows) {
if (row.Date.getUTCFullYear() === 2017) {
byMonth.set(row.Date.getUTCMonth(), row)
}
}
return [...byMonth.values()]
}
export function observedBrushDates(rows: readonly AaplRow[]) {
return rows.map((row) => row.Date)
}
export function brushDomain(dates: readonly Date[]): readonly [Date, Date] {
const first = dates[0]
const last = dates.at(-1)
if (!first || !last) throw new Error('Brush selection requires dates.')
return [first, last]
}
export function initialBrushRange(dates: readonly Date[]): BrushRange {
const start = dates[3]
const end = dates[5]
if (!start || !end) throw new Error('Brush selection requires six dates.')
return { start, end }
}
export function brushDateKey(date: Date) {
return date.toISOString().slice(0, 10)
}
export function brushShortDate(date: Date) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
}
export function brushDateFromAnchor(dates: readonly Date[], anchor: string) {
const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
return dates.find((date) => brushDateKey(date) === key) ?? null
}
export function clampBrushDate(dates: readonly Date[], date: Date) {
const first = dates[0]
if (!first) throw new Error('Brush selection requires dates.')
return dates.reduce((nearest, candidate) =>
Math.abs(candidate.getTime() - date.getTime()) <
Math.abs(nearest.getTime() - date.getTime())
? candidate
: nearest,
)
}
export function normalizedBrushRange(a: Date, b: Date): BrushRange {
return a.getTime() <= b.getTime()
? { start: a, end: b }
: { start: b, end: a }
}
export function brushRowsInRange(rows: readonly AaplRow[], range: BrushRange) {
const start = range.start.getTime()
const end = range.end.getTime()
return rows.filter((row) => {
const timestamp = row.Date.getTime()
return timestamp >= start && timestamp <= end
})
}
export function brushRangeSummary(rows: readonly AaplRow[], range: BrushRange) {
const selected = brushRowsInRange(rows, range)
const total = selected.reduce((sum, row) => sum + row.Close, 0)
const first = selected[0]
const last = selected.at(-1)
return {
count: selected.length,
average: selected.length ? total / selected.length : 0,
change: first && last ? last.Close - first.Close : 0,
}
}cases/89-brush-range-selection/paint.ts92 lines · support
cases/89-brush-range-selection/paint.ts
export const brushSelectionFill = 'rgba(147, 197, 253, 0.4)'
export function normalizedElementFill(element: Element) {
const view = element.ownerDocument.defaultView
if (!view) return null
const style = view.getComputedStyle(element)
return normalizedRenderedFill(style.fill, style.fillOpacity, style.opacity)
}
export function normalizedRenderedFill(
fill: string,
fillOpacity: string | number | undefined,
opacity: string | number | undefined,
) {
const color = parseColor(fill)
if (!color) return null
const alpha =
color.alpha * numericOpacity(fillOpacity) * numericOpacity(opacity)
return `rgba(${color.red}, ${color.green}, ${color.blue}, ${formatAlpha(alpha)})`
}
interface ParsedColor {
red: number
green: number
blue: number
alpha: number
}
function parseColor(value: string): ParsedColor | null {
const normalized = value.trim().toLowerCase()
if (normalized === 'transparent') {
return { red: 0, green: 0, blue: 0, alpha: 0 }
}
const hex = /^#([\da-f]{6})([\da-f]{2})?$/.exec(normalized)
if (hex) {
const channels = hex[1]
if (!channels) return null
return {
red: Number.parseInt(channels.slice(0, 2), 16),
green: Number.parseInt(channels.slice(2, 4), 16),
blue: Number.parseInt(channels.slice(4, 6), 16),
alpha: hex[2] ? Number.parseInt(hex[2], 16) / 255 : 1,
}
}
const functional = /^rgba?\((.*)\)$/.exec(normalized)
if (!functional?.[1]) return null
const [channelText, slashAlpha] = functional[1]
.split('/')
.map((part) => part.trim())
if (!channelText) return null
const commaParts = channelText.split(',').map((part) => part.trim())
const parts =
commaParts.length > 1
? commaParts
: channelText.split(/\s+/).filter(Boolean)
if (parts.length < 3) return null
const red = colorChannel(parts[0])
const green = colorChannel(parts[1])
const blue = colorChannel(parts[2])
const inlineAlpha = commaParts.length > 3 ? commaParts[3] : undefined
const alpha = alphaChannel(slashAlpha ?? inlineAlpha ?? '1')
if (red === null || green === null || blue === null || alpha === null) {
return null
}
return { red, green, blue, alpha }
}
function colorChannel(value: string | undefined) {
if (!value) return null
const numeric = Number.parseFloat(value)
if (!Number.isFinite(numeric)) return null
return Math.round(
Math.min(255, Math.max(0, value.endsWith('%') ? numeric * 2.55 : numeric)),
)
}
function alphaChannel(value: string) {
const numeric = Number.parseFloat(value)
if (!Number.isFinite(numeric)) return null
return Math.min(1, Math.max(0, value.endsWith('%') ? numeric / 100 : numeric))
}
function numericOpacity(value: string | number | undefined) {
const numeric = Number(value ?? 1)
return Number.isFinite(numeric) ? numeric : 1
}
function formatAlpha(value: number) {
return String(Math.round(Math.min(1, Math.max(0, value)) * 10_000) / 10_000)
}