Source

550 chart lines · 2 files · 16.5 kB

Apple daily stock prices1,260 records · CSV · 92.4 kB
@charts-poc/demo-data/aapl

Selection: 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
}