TanStack
Catalog

Focus and context time window

interaction

404 lines · 2 files · 11.4 kB

cases/83-focus-context-window/example.tsx327 lines · entry
cases/83-focus-context-window/example.tsx
import { useCallback, useMemo, useRef, useState } from 'react'
import { defineChart, dot, lineY } from '@tanstack/charts'
import { brushX } from '@tanstack/charts/interaction/brush'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { keyedSelection, whenSelected } from '@tanstack/charts/selection'
import { Chart } from '@tanstack/charts/react'
import { aapl } from '@tanstack/charts-data/aapl'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
  dateKey,
  focusContextDomain,
  initialFocusContextWindow,
  monthlyAaplRows,
  rowsInWindow,
  windowForDate,
} from './model'
import type { ChartScene } from '@tanstack/charts'
import type {
  BrushRange,
  BrushXChange,
} from '@tanstack/charts/interaction/brush'
import type { KeyedSelectionChange } from '@tanstack/charts/selection'
import type { AaplRow } from '@tanstack/charts-data/aapl'
import type { FocusContextWindow } from './model'

interface BrushStatus {
  dragging: boolean
  outcome: 'idle' | 'dragging' | 'commit' | 'cancel'
}

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)

export function focusContextDetailDefinition(window: FocusContextWindow) {
  const rows = rowsInWindow(focusContextRows, window)
  const selection = keyedSelection<AaplRow, string, Date, number>({
    selected: controlledSignal<
      string | null,
      KeyedSelectionChange<AaplRow, string, Date, number>
    >(dateKey(window.selected), () => {}),
    key: (row) => dateKey(row.Date),
  })
  return defineChart(
    {
      marks: [
        lineY(rows, {
          id: 'detail-line',
          x: 'Date',
          y: 'Close',
          stroke: '#2563eb',
          strokeWidth: 2.5,
        }),
        dot(rows, {
          id: 'detail-points',
          x: 'Date',
          y: 'Close',
          fill: '#2563eb',
          r: 3,
        }),
        whenSelected(
          dot(rows, {
            id: 'selected-point',
            x: 'Date',
            y: 'Close',
            fill: '#f97316',
            stroke: '#ffffff',
            strokeWidth: 2,
            r: 6,
          }),
          selection,
        ),
      ],
      scales: {
        x: {
          scale: scaleUtc().domain([window.start, window.end]),
          axis: { label: 'Selected time window' },
        },
        y: {
          scale: scaleLinear,
          grid: true,
          axis: { label: 'Close ($)' },
        },
      },

      margin: detailMargin,
    },
    { svgAnimation: false, keyboard: false },
  )
}

export function focusContextOverviewDefinition(
  window: FocusContextWindow,
  onChange: (range: BrushRange<Date>, reason: BrushXChange<Date>) => void,
) {
  return defineChart(
    {
      marks: [
        lineY(focusContextRows, {
          id: 'overview-line',
          x: 'Date',
          y: 'Close',
          stroke: '#2563eb',
          strokeWidth: 1.75,
        }),
      ],
      scales: {
        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,
      controls: [
        brushX({
          id: 'focus-window',
          range: controlledSignal<BrushRange<Date>, BrushXChange<Date>>(
            { start: window.start, end: window.end },
            (next, { reason }) => onChange(next, reason),
          ),
          values: focusContextDates,
          ariaLabel: 'Selected time window',
          startAriaLabel: 'Selected time window start',
          endAriaLabel: 'Selected time window end',
          format: monthLabel,
          handleSize: 16,
          selectionStyle: {
            fill: '#2563eb',
            fillOpacity: 0.16,
            stroke: '#2563eb',
            strokeWidth: 1.5,
          },
          handleStyle: { fill: '#2563eb', fillOpacity: 0.9 },
        }),
      ],
    },
    { svgAnimation: false, keyboard: false },
  )
}

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 rangeLabel(window: FocusContextWindow) {
  return `${monthLabel(window.start)}${monthLabel(window.end)}`
}

function monthLabel(date: Date) {
  return date.toLocaleDateString(undefined, {
    month: 'short',
    year: 'numeric',
    timeZone: 'UTC',
  })
}

function selectedIndex(window: FocusContextWindow) {
  return Math.max(
    0,
    focusContextDates.findIndex(
      (date) => date.getTime() === window.selected.getTime(),
    ),
  )
}

function nearestDateInRange(range: BrushRange<Date>) {
  const midpoint = (range.start.getTime() + range.end.getTime()) / 2
  return focusContextDates.reduce((candidate, date) =>
    Math.abs(date.getTime() - midpoint) <
    Math.abs(candidate.getTime() - midpoint)
      ? date
      : candidate,
  )
}

export interface ExampleProps {
  width?: number
  height?: number
  revision?: number
}

export default function FocusContextExample({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const input = { width, height, revision, preview: false, interactive: true }
  const idPrefix = '83-focus-context-window'
  const detailSurfaceRef = useRef<HTMLDivElement>(null)

  const overviewSurfaceRef = useRef<HTMLDivElement>(null)

  const overviewSceneRef = useRef<ChartScene<AaplRow> | null>(null)

  const windowRef = useRef(initialFocusContextWindow(focusContextDates))

  const brushStatusRef = useRef<BrushStatus>({
    dragging: false,
    outcome: 'idle',
  })

  const [window, setWindowState] = useState(windowRef.current)

  const heights = viewHeights(input.height)

  const detailDefinition = useMemo(
    () => focusContextDetailDefinition(window),
    [window.start, window.end, window.selected],
  )

  const chooseDate = useCallback((date: Date) => {
    const next = windowForDate(focusContextDates, date)
    windowRef.current = next
    setWindowState(next)
  }, [])

  const handleBrushChange = useCallback(
    (range: BrushRange<Date>, reason: BrushXChange<Date>) => {
      brushStatusRef.current = {
        dragging: reason.type === 'preview',
        outcome: reason.type === 'preview' ? 'dragging' : reason.type,
      }
      if (reason.type !== 'commit') return
      chooseDate(nearestDateInRange(range))
    },
    [chooseDate],
  )

  const overviewDefinition = useMemo(
    () => focusContextOverviewDefinition(window, handleBrushChange),
    [handleBrushChange, window.end, window.start],
  )

  return (
    <div
      style={{
        display: 'grid',
        gridTemplateRows: `${heights.detail}px ${heights.overview}px ${heights.controls}px`,
        gap,
        width: input.width,
        height: input.height,
      }}
    >
      <div ref={detailSurfaceRef} data-conformance-view="detail">
        <Chart
          idPrefix={idPrefix ? `${idPrefix}-detail` : undefined}
          definition={detailDefinition}
          width={input.width}
          height={heights.detail}
          ariaLabel="Detail time window"
        />
      </div>
      <div
        ref={overviewSurfaceRef}
        data-conformance-view="overview"
        style={{ position: 'relative' }}
      >
        <Chart
          idPrefix={idPrefix ? `${idPrefix}-overview` : undefined}
          definition={overviewDefinition}
          width={input.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."
          onRender={({ scene }) => {
            overviewSceneRef.current = scene
          }}
        />
      </div>
      <div
        data-focus-controls=""
        style={{
          display: 'grid',
          gridTemplateColumns: 'minmax(9rem, 1fr) auto',
          alignItems: 'center',
          gap: 10,
          padding: '4px 12px',
          boxSizing: 'border-box',
          font: '600 11px/1.25 system-ui, sans-serif',
        }}
      >
        <input
          type="range"
          min={0}
          max={focusContextDates.length - 1}
          step={1}
          value={selectedIndex(window)}
          aria-label="Selected month"
          aria-description="Use arrow keys, Home, or End to move the four-month detail window."
          onChange={(event) => {
            const date = focusContextDates[Number(event.currentTarget.value)]
            if (date) chooseDate(date)
          }}
          style={{ width: '100%', minHeight: 44, cursor: 'pointer' }}
        />
        <output data-focus-range="" aria-live="polite">
          {rangeLabel(window)}
        </output>
      </div>
    </div>
  )
}
cases/83-focus-context-window/model.ts77 lines · dependency
cases/83-focus-context-window/model.ts
import type { AaplRow } from '@tanstack/charts-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
}