Source
550 chart lines · 2 files · 16.5 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/83-focus-context-window/tanstack.ts473 lines · entry
cases/83-focus-context-window/tanstack.ts
import { defineChart, dot, lineY, mountChart } from '@tanstack/charts'
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 {
dateFromAnchor,
dateKey,
focusContextDomain,
initialFocusContextWindow,
monthlyAaplRows,
rowsInWindow,
windowForDate,
} from './model'
import type { ChartHost, ChartHostOptions } from '@tanstack/charts'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { BrushSelection, D3BrushEvent } from 'd3-brush'
import type { FocusContextWindow } from './model'
import type {
ConformanceHandle,
ConformanceInput,
ConformanceTarget,
} from '../../types'
interface DetailInput extends ConformanceInput {
window: FocusContextWindow
}
const detailMargin = { top: 16, right: 24, bottom: 38, left: 52 }
const overviewMargin = { top: 8, right: 24, bottom: 22, left: 52 }
const gap = 8
const focusContextRows = monthlyAaplRows(aapl)
const focusContextDates = focusContextRows.map((row) => row.Date)
const fullDomain = focusContextDomain(focusContextRows)
const detailDefinition = (input: DetailInput) => {
const rows = rowsInWindow(focusContextRows, input.window)
const selectedRows = rows.filter(
(row) => row.Date.getTime() === input.window.selected.getTime(),
)
return defineChart({
marks: [
lineY(rows, {
x: 'Date',
y: 'Close',
stroke: '#2563eb',
strokeWidth: 2.5,
}),
dot(rows, {
x: 'Date',
y: 'Close',
fill: '#2563eb',
r: 3,
}),
dot(selectedRows, {
x: 'Date',
y: 'Close',
fill: '#f97316',
stroke: '#ffffff',
strokeWidth: 2,
r: 6,
}),
],
x: {
scale: scaleUtc().domain([input.window.start, input.window.end]),
axis: { label: 'Selected time window' },
},
y: { scale: scaleLinear, grid: true, axis: { label: 'Close ($)' } },
margin: detailMargin,
})
}
const overviewDefinition = (input: ConformanceInput) => {
const rows = focusContextRows
return defineChart({
marks: [
lineY(rows, {
x: 'Date',
y: 'Close',
stroke: '#2563eb',
strokeWidth: 1.75,
}),
],
x: {
scale: scaleUtc().domain(fullDomain),
axis: {
ticks: {
count: 4,
format: (value) =>
value.toLocaleDateString(undefined, {
month: 'short',
timeZone: 'UTC',
}),
},
},
},
y: { scale: scaleLinear, axis: false },
margin: overviewMargin,
})
}
function viewHeights(height: number) {
const controls = 52
const overview = Math.max(56, Math.min(100, Math.round(height * 0.24)))
return {
detail: Math.max(1, height - overview - controls - gap * 2),
overview,
controls,
}
}
function targetDate(target: ConformanceTarget) {
if (target.view !== 'overview') return null
return dateFromAnchor(focusContextDates, target.anchor)
}
function scenePointToClient(
surface: HTMLElement,
host: ChartHost<AaplRow>,
x: number,
y: number,
) {
const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
if (!svg) return null
const scene = host.getScene()
const bounds = svg.getBoundingClientRect()
return {
x: bounds.left + (x / scene.width) * bounds.width,
y: bounds.top + (y / scene.height) * bounds.height,
focusElement: svg,
}
}
export function mount(
container: HTMLElement,
input: ConformanceInput,
): ConformanceHandle {
const document = container.ownerDocument
const shell = document.createElement('div')
const detailView = document.createElement('div')
const overviewView = document.createElement('div')
const detailSurface = document.createElement('div')
const overviewSurface = document.createElement('div')
const brushOverlay = document.createElementNS(
'http://www.w3.org/2000/svg',
'svg',
)
const brushGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const controls = document.createElement('div')
const monthControl = document.createElement('input')
const rangeLabel = document.createElement('output')
detailView.dataset.conformanceView = 'detail'
overviewView.dataset.conformanceView = 'overview'
controls.dataset.focusControls = ''
overviewView.style.position = 'relative'
brushOverlay.dataset.focusWindow = ''
brushOverlay.setAttribute('aria-hidden', 'true')
brushOverlay.append(brushGroup)
Object.assign(brushOverlay.style, {
position: 'absolute',
zIndex: '1',
inset: '0',
width: '100%',
height: '100%',
touchAction: 'none',
})
controls.style.display = 'grid'
controls.style.gridTemplateColumns = 'minmax(9rem, 1fr) auto'
controls.style.alignItems = 'center'
controls.style.gap = '10px'
controls.style.padding = '4px 12px'
controls.style.boxSizing = 'border-box'
controls.style.font = '600 11px/1.25 system-ui, sans-serif'
monthControl.type = 'range'
monthControl.min = '0'
monthControl.max = String(focusContextDates.length - 1)
monthControl.step = '1'
monthControl.setAttribute('aria-label', 'Selected month')
monthControl.setAttribute(
'aria-description',
'Use arrow keys, Home, or End to move the four-month detail window.',
)
monthControl.style.width = '100%'
monthControl.style.minHeight = '44px'
monthControl.style.cursor = 'pointer'
rangeLabel.dataset.focusRange = ''
rangeLabel.setAttribute('aria-live', 'polite')
rangeLabel.style.whiteSpace = 'nowrap'
controls.append(monthControl, rangeLabel)
detailView.append(detailSurface)
overviewView.append(overviewSurface, brushOverlay)
shell.append(detailView, overviewView, controls)
container.append(shell)
let currentInput = input
let window = initialFocusContextWindow(focusContextDates)
const sizeShell = () => {
const heights = viewHeights(currentInput.height)
shell.style.width = `${currentInput.width}px`
shell.style.height = `${currentInput.height}px`
shell.style.display = 'grid'
shell.style.gridTemplateRows = `${heights.detail}px ${heights.overview}px ${heights.controls}px`
shell.style.gap = `${gap}px`
return heights
}
const detailOptions = (): ChartHostOptions<AaplRow> => {
const heights = viewHeights(currentInput.height)
return {
definition: defineChart(detailDefinition({ ...currentInput, window }), {
animate: false,
keyboard: false,
}),
width: currentInput.width,
height: heights.detail,
ariaLabel: 'Detail time window',
}
}
const overviewOptions = (): ChartHostOptions<AaplRow> => {
const heights = viewHeights(currentInput.height)
return {
definition: defineChart(overviewDefinition(currentInput), {
animate: false,
keyboard: false,
}),
width: currentInput.width,
height: heights.overview,
ariaLabel: 'Overview time series with draggable detail window',
ariaDescription:
'Drag the visible selection or use the range control below to reposition the four-month detail window.',
}
}
sizeShell()
const detailHost = mountChart(detailSurface, detailOptions())
const overviewHost = mountChart(overviewSurface, overviewOptions())
let movingBrush = false
let brushDragging = false
let brushOrigin: FocusContextWindow | null = null
let cancelRequested = false
let brushOutcome = 'idle'
const brush = brushX<unknown>()
.touchable(true)
.handleSize(16)
.on('start', (event: D3BrushEvent<unknown>) => {
if (movingBrush || !event.sourceEvent) return
brushOrigin = { ...window }
brushDragging = true
cancelRequested = false
brushOutcome = 'dragging'
})
.on('end', (event: D3BrushEvent<unknown>) => {
if (movingBrush || !event.sourceEvent) return
const touchCancelled =
event.sourceEvent instanceof Event &&
event.sourceEvent.type === 'touchcancel'
if (cancelRequested || touchCancelled) {
if (brushOrigin) window = brushOrigin
brushDragging = false
brushOrigin = null
cancelRequested = false
brushOutcome = 'cancel'
detailHost.update(detailOptions())
paintWindow()
return
}
brushDragging = false
brushOrigin = null
brushOutcome = 'commit'
const selection = event.selection
if (selection) {
const range = horizontalBrushRange(selection)
if (range) {
chooseDate(nearestDate((range[0] + range[1]) / 2))
return
}
}
if ('clientX' in event.sourceEvent) {
const bounds = brushOverlay.getBoundingClientRect()
const scene = overviewHost.getScene()
const sceneX =
((Number(event.sourceEvent.clientX) - bounds.left) / bounds.width) *
scene.width
chooseDate(nearestDate(sceneX))
}
})
const configureBrush = () => {
const scene = overviewHost.getScene()
brush.extent([
[scene.chart.x, scene.chart.y],
[scene.chart.x + scene.chart.width, scene.chart.y + scene.chart.height],
])
brushOverlay.setAttribute('viewBox', `0 0 ${scene.width} ${scene.height}`)
select(brushGroup).call(brush)
styleBrush(brushGroup)
}
const paintWindow = () => {
const scene = overviewHost.getScene()
const left = scene.scales.x.map(window.start)
const right = scene.scales.x.map(window.end)
movingBrush = true
select(brushGroup).call(brush.move, [left, right])
movingBrush = false
styleBrush(brushGroup)
monthControl.value = String(
Math.max(
0,
focusContextDates.findIndex(
(date) => date.getTime() === window.selected.getTime(),
),
),
)
rangeLabel.value = `${monthLabel(window.start)} – ${monthLabel(window.end)}`
rangeLabel.textContent = rangeLabel.value
}
const chooseDate = (date: Date) => {
window = windowForDate(focusContextDates, date)
detailHost.update(detailOptions())
paintWindow()
}
const nearestDate = (sceneX: number) => {
const scene = overviewHost.getScene()
return focusContextDates.reduce((candidate, date) =>
Math.abs(scene.scales.x.map(date) - sceneX) <
Math.abs(scene.scales.x.map(candidate) - sceneX)
? date
: candidate,
)
}
const handleMonthInput = () => {
const date = focusContextDates[Number(monthControl.value)]
if (date) chooseDate(date)
}
const cancelActiveBrush = () => {
if (!brushDragging || !brushOrigin) return
cancelRequested = true
window = brushOrigin
brushDragging = false
brushOutcome = 'cancel'
detailHost.update(detailOptions())
paintWindow()
}
monthControl.addEventListener('input', handleMonthInput)
brushOverlay.addEventListener('pointercancel', cancelActiveBrush)
brushOverlay.addEventListener('touchcancel', cancelActiveBrush)
configureBrush()
paintWindow()
return {
update(nextInput) {
currentInput = nextInput
sizeShell()
overviewHost.update(overviewOptions())
detailHost.update(detailOptions())
configureBrush()
paintWindow()
},
driver: {
resolveTarget(target) {
if (
target.view === 'overview' &&
target.anchor === 'control:selected-month'
) {
return center(monthControl)
}
const date = targetDate(target)
if (!date) return null
const scene = overviewHost.getScene()
return scenePointToClient(
overviewSurface,
overviewHost,
scene.scales.x.map(date),
scene.chart.y + scene.chart.height / 2,
)
},
readState() {
const detailRows = rowsInWindow(focusContextRows, window)
const selectedRow = detailRows.find(
(row) => row.Date.getTime() === window.selected.getTime(),
)
return {
window: {
selected: dateKey(window.selected),
start: dateKey(window.start),
end: dateKey(window.end),
},
detail: {
pointCount: detailRows.length,
selectedValue: selectedRow?.Close ?? null,
},
control: {
value: Number(monthControl.value),
label: rangeLabel.value,
},
brush: brushSelectionState(brushGroup, brushDragging, brushOutcome),
}
},
},
destroy() {
monthControl.removeEventListener('input', handleMonthInput)
brushOverlay.removeEventListener('pointercancel', cancelActiveBrush)
brushOverlay.removeEventListener('touchcancel', cancelActiveBrush)
select(brushGroup).on('.brush', null)
detailHost.destroy()
overviewHost.destroy()
shell.remove()
},
}
}
function styleBrush(group: SVGGElement) {
const selection = group.querySelector<SVGRectElement>('.selection')
if (selection) {
selection.setAttribute('fill', '#2563eb')
selection.setAttribute('fill-opacity', '0.16')
selection.setAttribute('stroke', '#2563eb')
selection.setAttribute('stroke-width', '1.5')
selection.style.cursor = 'grab'
}
for (const handle of group.querySelectorAll<SVGRectElement>('.handle')) {
handle.setAttribute('fill', '#2563eb')
handle.setAttribute('fill-opacity', '0.9')
handle.setAttribute('width', '8')
}
}
function monthLabel(date: Date) {
return date.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})
}
function brushSelectionState(
group: SVGGElement,
dragging: boolean,
outcome: string,
) {
const selection = group.querySelector<SVGRectElement>('.selection')
return {
x: Number(selection?.getAttribute('x') ?? 0),
width: Number(selection?.getAttribute('width') ?? 0),
dragging,
outcome,
}
}
function horizontalBrushRange(
selection: BrushSelection,
): readonly [number, number] | null {
const [left, right] = selection
return typeof left === 'number' && typeof right === 'number'
? [left, right]
: null
}
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/83-focus-context-window/model.ts77 lines · support
cases/83-focus-context-window/model.ts
import type { AaplRow } from '@charts-poc/demo-data/aapl'
export interface FocusContextWindow {
selected: Date
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 focusContextDomain(
rows: readonly AaplRow[],
): readonly [Date, Date] {
const first = rows[0]
const last = rows.at(-1)
if (!first || !last) throw new Error('Focus/context requires AAPL rows.')
return [first.Date, last.Date]
}
export function initialFocusContextWindow(
dates: readonly Date[],
): FocusContextWindow {
const selected = dates[5] ?? dates[0]
if (!selected) throw new Error('Focus/context requires observed dates.')
return windowForDate(dates, selected)
}
export function windowForDate(
dates: readonly Date[],
selected: Date,
): FocusContextWindow {
const selectedIndex = Math.max(
0,
dates.findIndex((date) => date.getTime() === selected.getTime()),
)
const startIndex = Math.min(dates.length - 4, Math.max(0, selectedIndex - 1))
const selectedDate = dates[selectedIndex]
const start = dates[startIndex]
const end = dates[startIndex + 3]
if (!selectedDate || !start || !end) {
throw new Error('Focus/context requires at least four observed dates.')
}
return {
selected: selectedDate,
start,
end,
}
}
export function rowsInWindow(
rows: readonly AaplRow[],
window: FocusContextWindow,
) {
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 dateKey(date: Date) {
return date.toISOString().slice(0, 10)
}
export function dateFromAnchor(dates: readonly Date[], anchor: string) {
const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
return dates.find((date) => dateKey(date) === key) ?? null
}