415 lines · 3 files · 12.0 kB
cases/89-brush-range-selection/example.tsx234 lines · entry
cases/89-brush-range-selection/example.tsx
import { useCallback, useMemo, useRef, useState } from 'react'
import { Chart } from '@tanstack/charts/react'
import { initialBrushRange, observedBrushDates, monthlyAaplRows } from './model'
import { aapl } from '@tanstack/charts-data/aapl'
import type { ChartScene } from '@tanstack/charts'
import type {
BrushRange,
BrushXChange,
} from '@tanstack/charts/interaction/brush'
import type { AaplRow } from '@tanstack/charts-data/aapl'
export interface BrushState {
range: BrushRange<Date>
dragging: boolean
}
import { defineChart, dot, lineY } from '@tanstack/charts'
import { brushX } from '@tanstack/charts/interaction/brush'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { decorative } from '@tanstack/charts/mark/decorative'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
brushDateKey,
brushDomain,
brushRangeSummary,
brushShortDate,
} from './model'
import { brushSelectionFill } from './paint'
const initialRange = initialBrushRange(
observedBrushDates(monthlyAaplRows(aapl)),
)
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
const color = '#2563eb'
const brushRows = monthlyAaplRows(aapl)
const brushDates = observedBrushDates(brushRows)
const fullDomain = brushDomain(brushDates)
const brushMonthFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
timeZone: 'UTC',
})
export function brushRangeDefinition(
range: BrushRange<Date>,
onChange: (range: BrushRange<Date>, reason: BrushXChange<Date>) => void,
) {
return defineChart({
marks: [
decorative(
lineY(brushRows, {
id: 'brush-series-line',
x: 'Date',
y: 'Close',
stroke: color,
strokeWidth: 2.5,
}),
),
dot(brushRows, {
id: 'brush-series-points',
x: 'Date',
y: 'Close',
fill: color,
r: 3.5,
stroke: '#ffffff',
strokeWidth: 1,
}),
],
scales: {
x: {
scale: scaleUtc().domain(fullDomain),
axis: {
ticks: { format: (value) => brushMonthFormatter.format(value) },
label: 'Month',
},
},
y: {
scale: scaleLinear,
grid: true,
axis: { ticks: { count: 4 }, label: 'AAPL close ($)' },
},
},
controls: [
brushX({
id: 'monthly-range',
range: controlledSignal<BrushRange<Date>, BrushXChange<Date>>(
range,
(next, { reason }) => onChange(next, reason),
),
values: brushDates,
ariaLabel:
'Monthly range brush. Drag to select; focus either handle and use arrow keys, Home, or End to adjust.',
startAriaLabel: 'Range start',
endAriaLabel: 'Range end',
format: brushDateKey,
handleSize: 16,
selectionStyle: {
fill: brushSelectionFill,
fillOpacity: 1,
stroke: color,
strokeWidth: 1,
},
handleStyle: {
fill: 'Canvas',
fillOpacity: 1,
stroke: color,
strokeWidth: 2,
},
}),
],
svgAnimation: false,
keyboard: false,
focusRing: false,
margin: { top: 52, right: 24, bottom: 44, left: 58 },
})
}
export function brushRangeStatus(range: BrushRange<Date>) {
const summary = brushRangeSummary(brushRows, range)
return {
label: `${brushShortDate(range.start)} → ${brushShortDate(range.end)} · ${summary.count} AAPL closes · avg $${summary.average.toFixed(1)}`,
ariaLabel: `${brushDateKey(range.start)} through ${brushDateKey(range.end)}, ${summary.count} AAPL closing prices, average $${summary.average.toFixed(1)}`,
}
}
export function copyRange(range: BrushRange<Date>): BrushRange<Date> {
return {
start: new Date(range.start.getTime()),
end: new Date(range.end.getTime()),
}
}
export default function BrushRangeExample({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '89-brush-range-selection'
const shellRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<HTMLDivElement>(null)
const sceneRef = useRef<ChartScene<AaplRow, Date, number>>(null)
const [accepted, setAccepted] = useState(() => copyRange(initialRange))
const [state, setState] = useState<BrushState>(() => ({
range: copyRange(initialRange),
dragging: false,
}))
const stateRef = useRef(state)
stateRef.current = state
const handleBrushChange = useCallback(
(next: BrushRange<Date>, reason: BrushXChange<Date>) => {
const nextState = {
range: copyRange(next),
dragging: reason.type === 'preview',
}
stateRef.current = nextState
setState(nextState)
if (reason.type !== 'preview') setAccepted(copyRange(next))
},
[],
)
const definition = useMemo(
() => brushRangeDefinition(accepted, handleBrushChange),
[accepted, handleBrushChange],
)
const status = brushRangeStatus(state.range)
return (
<div
ref={shellRef}
data-conformance-view="main"
role="application"
aria-label="Monthly time range brush with two adjustable handles"
style={{ position: 'relative', width: input.width, height: input.height }}
>
<div
ref={chartRef}
style={{
position: 'relative',
width: input.width,
height: input.height,
}}
>
<Chart
idPrefix={idPrefix}
definition={definition}
width={input.width}
height={input.height}
ariaLabel="Time series with a draggable horizontal range brush"
onRender={({ scene }) => {
sceneRef.current = scene
}}
/>
</div>
<output
role="status"
aria-live="polite"
aria-label={status.ariaLabel}
style={{
position: 'absolute',
right: 24,
top: 10,
zIndex: 4,
padding: '4px 8px',
border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)',
borderRadius: 999,
background: 'Canvas',
color: 'CanvasText',
font: '600 12px/1.2 system-ui, sans-serif',
pointerEvents: 'none',
}}
>
{status.label}
</output>
</div>
)
}cases/89-brush-range-selection/model.ts89 lines · dependency
cases/89-brush-range-selection/model.ts
import type { AaplRow } from '@tanstack/charts-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 · dependency
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)
}