TanStack
Catalog

Observed monthly AAPL brush selection

interaction

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)
}