Source

394 chart lines · 2 files · 13.1 kB

Palmer penguins344 records · CSV · 13.5 kB
@charts-poc/demo-data/penguins

Selection: Complete published snapshot

species
string
island
string
culmen_length_mm
number | null
culmen_depth_mm
number | null
flipper_length_mm
number | null
body_mass_g
number | null
sex
string | null

Dr. Kristen Gorman / Palmer Station LTER@observablehq/sample-datasets@1.0.1 · revision 732c0148de74 · penguins.csv · ISC distribution; upstream source credited · SHA-256 dfee817d1c14Pinned snapshot

cases/82-chart-table-selection/tanstack.ts338 lines · entry
cases/82-chart-table-selection/tanstack.ts
import { defineChart, dot, mountChart } from '@tanstack/charts'
import { penguins } from '@charts-poc/demo-data/penguins'
import { scaleLinear } from 'd3-scale'
import {
  isSelectionId,
  penguinSelectionId,
  penguinSelectionLabel,
  selectionRows,
} from './model'
import type { ChartHost, ChartHostOptions } from '@tanstack/charts'
import type {
  ConformanceInput,
  ConformanceMount,
  ConformanceTarget,
} from '../../types'
import type { CompletePenguin, SelectionId } from './model'

interface ChartTableInput extends ConformanceInput {
  selectedId: SelectionId | null
}

const definition = (input: ChartTableInput) => {
  const rows = selectionRows(penguins, input.revision)
  const selectedRows = rows.filter(
    (row) => penguinSelectionId(row) === input.selectedId,
  )

  return defineChart({
    marks: [
      dot(rows, {
        id: 'observations',
        x: 'flipper_length_mm',
        y: 'body_mass_g',
        r: 4.5,
        fill: '#2563eb',
      }),
      ...(selectedRows.length
        ? [
            dot(selectedRows, {
              id: 'selected-observation',
              x: 'flipper_length_mm',
              y: 'body_mass_g',
              r: 7,
              fill: '#f97316',
              stroke: '#ffffff',
              strokeWidth: 2,
            }),
          ]
        : []),
    ],
    x: { scale: scaleLinear, axis: { label: 'Flipper length (mm)' } },
    y: {
      scale: scaleLinear,
      grid: true,
      axis: { ticks: { count: 5 }, label: 'Body mass (g)' },
    },
    margin: { top: 16, right: 24, bottom: 42, left: 62 },
  })
}

function selectionFromTarget(target: ConformanceTarget) {
  if (target.view !== undefined && target.view !== 'main') return null
  const [kind, id] = target.anchor.split(':')
  return (kind === 'point' || kind === 'row') && isSelectionId(id)
    ? { kind, id }
    : null
}

function pointCoordinate(
  chartSurface: HTMLElement,
  host: ChartHost<CompletePenguin>,
  pointId: SelectionId,
) {
  const scene = host.getScene()
  const point = scene.points.find(
    (candidate) =>
      candidate.markId === 'observations' &&
      penguinSelectionId(candidate.datum) === pointId,
  )
  const svg = chartSurface.querySelector<SVGSVGElement>('svg')
  if (!point || !svg) return null
  const bounds = svg.getBoundingClientRect()
  return {
    x: bounds.left + (point.x / scene.width) * bounds.width,
    y: bounds.top + (point.y / scene.height) * bounds.height,
    focusElement: svg,
  }
}

export const mount: ConformanceMount = (container, input) => {
  const view = container.ownerDocument.createElement('div')
  view.dataset.conformanceView = 'main'
  view.setAttribute('role', 'region')
  view.setAttribute('aria-label', 'Selectable observations with data table')
  view.style.width = `${input.width}px`
  view.style.height = `${input.height}px`
  view.style.display = 'grid'

  const chartSurface = container.ownerDocument.createElement('div')
  const selectionStatus = container.ownerDocument.createElement('div')
  const selectionText = container.ownerDocument.createElement('span')
  const clearButton = container.ownerDocument.createElement('button')
  const tableScroller = container.ownerDocument.createElement('div')
  const table = container.ownerDocument.createElement('table')
  selectionText.setAttribute('role', 'status')
  selectionText.setAttribute('aria-live', 'polite')
  selectionText.dataset.selectionStatus = ''
  selectionStatus.style.display = 'flex'
  selectionStatus.style.alignItems = 'center'
  selectionStatus.style.justifyContent = 'space-between'
  selectionStatus.style.minHeight = '52px'
  selectionStatus.style.padding = '4px 8px'
  selectionStatus.style.boxSizing = 'border-box'
  selectionStatus.style.gap = '8px'
  selectionStatus.style.font = '600 12px/1.3 system-ui, sans-serif'
  clearButton.type = 'button'
  clearButton.textContent = 'Clear selection'
  clearButton.dataset.clearSelection = ''
  Object.assign(clearButton.style, {
    minWidth: '112px',
    minHeight: '44px',
    padding: '8px 10px',
    border: '1px solid color-mix(in srgb, CanvasText 25%, transparent)',
    borderRadius: '5px',
    background: 'Canvas',
    color: 'CanvasText',
    cursor: 'pointer',
    font: 'inherit',
  })
  selectionStatus.append(selectionText, clearButton)
  table.setAttribute('aria-label', 'Observation values')
  table.style.width = '100%'
  table.style.borderCollapse = 'collapse'
  table.style.fontSize = '12px'
  tableScroller.style.overflow = 'auto'
  tableScroller.append(table)

  view.append(chartSurface, selectionStatus, tableScroller)
  container.append(view)

  let currentInput = input
  let selectedId: SelectionId | null = null
  let host: ChartHost<CompletePenguin>
  const rowElements = new Map<
    SelectionId,
    {
      row: HTMLTableRowElement
      button: HTMLButtonElement
      period: HTMLTableCellElement
      value: HTMLTableCellElement
    }
  >()

  const chartHeight = () => Math.max(96, currentInput.height - 204)
  const sizeLayout = () => {
    const chart = chartHeight()
    const tableHeight = Math.max(44, currentInput.height - chart - 52)
    view.style.gridTemplateRows = `${chart}px 52px ${tableHeight}px`
  }

  const options = (): ChartHostOptions<CompletePenguin> => ({
    definition: defineChart(
      definition({
        ...currentInput,
        selectedId,
      }),
      { animate: false, keyboard: true, maxFocusDistance: 40 },
    ),
    width: currentInput.width,
    height: chartHeight(),
    ariaLabel: 'Selectable observations chart',
    ariaDescription:
      'Use arrow keys to move between observations and Enter or Space to select one. The table below offers the same selections.',
    onSelect(point) {
      select(point ? penguinSelectionId(point.datum) : null)
    },
  })

  const updateTable = () => {
    const rows = selectionRows(penguins, currentInput.revision)
    for (const datum of rows) {
      const id = penguinSelectionId(datum)
      if (!id) continue
      const elements = rowElements.get(id)
      if (!elements) continue
      const selected = id === selectedId
      elements.period.textContent = datum.island
      elements.value.textContent = datum.body_mass_g.toLocaleString()
      elements.row.setAttribute('aria-selected', String(selected))
      elements.row.style.background = selected
        ? 'color-mix(in srgb, #f97316 16%, Canvas)'
        : 'Canvas'
      elements.row.style.color = 'CanvasText'
      elements.button.setAttribute('aria-pressed', String(selected))
      elements.button.style.fontWeight = selected ? '750' : '600'
    }
    const selectedDatum = rows.find(
      (row) => penguinSelectionId(row) === selectedId,
    )
    selectionText.textContent = selectedDatum
      ? `Selected ${penguinSelectionLabel(selectedDatum)}: ${selectedDatum.body_mass_g.toLocaleString()} g`
      : 'No observation selected'
    const clearDisabled = selectedId === null
    clearButton.setAttribute('aria-disabled', String(clearDisabled))
    clearButton.style.cursor = clearDisabled ? 'default' : 'pointer'
    clearButton.style.opacity = clearDisabled ? '0.55' : '1'
  }

  const select = (nextId: SelectionId | null) => {
    selectedId = nextId
    updateTable()
    host.update(options())
  }

  const head = container.ownerDocument.createElement('thead')
  const headerRow = container.ownerDocument.createElement('tr')
  for (const label of ['Island', 'Penguin', 'Body mass (g)']) {
    const cell = container.ownerDocument.createElement('th')
    cell.scope = 'col'
    cell.textContent = label
    cell.style.padding = '4px 8px'
    cell.style.textAlign = label === 'Body mass (g)' ? 'right' : 'left'
    headerRow.append(cell)
  }
  head.append(headerRow)
  const body = container.ownerDocument.createElement('tbody')
  for (const datum of selectionRows(penguins, currentInput.revision)) {
    const id = penguinSelectionId(datum)
    if (!id) continue
    const tableRow = container.ownerDocument.createElement('tr')
    const period = container.ownerDocument.createElement('td')
    const region = container.ownerDocument.createElement('th')
    const button = container.ownerDocument.createElement('button')
    const value = container.ownerDocument.createElement('td')
    tableRow.dataset.rowId = id
    tableRow.style.borderTop =
      '1px solid color-mix(in srgb, CanvasText 12%, transparent)'
    period.style.padding = '4px 8px'
    region.scope = 'row'
    region.style.padding = '0'
    button.type = 'button'
    button.dataset.rowSelect = id
    button.textContent = penguinSelectionLabel(datum)
    Object.assign(button.style, {
      width: '100%',
      minHeight: '44px',
      boxSizing: 'border-box',
      padding: '4px 8px',
      border: '0',
      background: 'transparent',
      color: 'inherit',
      cursor: 'pointer',
      textAlign: 'left',
      outlineOffset: '-3px',
    })
    button.addEventListener('click', () => select(id))
    region.append(button)
    value.style.padding = '4px 8px'
    value.style.textAlign = 'right'
    value.style.fontVariantNumeric = 'tabular-nums'
    tableRow.append(period, region, value)
    body.append(tableRow)
    rowElements.set(id, { row: tableRow, button, period, value })
  }
  table.replaceChildren(head, body)
  clearButton.addEventListener('click', () => select(null))
  sizeLayout()
  updateTable()
  host = mountChart(chartSurface, options())

  return {
    update(nextInput) {
      currentInput = nextInput
      view.style.width = `${nextInput.width}px`
      view.style.height = `${nextInput.height}px`
      sizeLayout()
      updateTable()
      host.update(options())
    },
    driver: {
      resolveTarget(target) {
        if (
          (target.view === undefined || target.view === 'main') &&
          target.anchor === 'control:clear-selection'
        ) {
          return center(clearButton)
        }
        const selection = selectionFromTarget(target)
        if (!selection) return null
        if (selection.kind === 'row') {
          const button = rowElements.get(selection.id)?.button
          return button ? center(button) : null
        }
        return pointCoordinate(chartSurface, host, selection.id)
      },
      readState() {
        const selectedRow = table.querySelector(
          '[data-row-id][aria-selected="true"]',
        )
        const selectedDatum = selectionRows(
          penguins,
          currentInput.revision,
        ).find((row) => penguinSelectionId(row) === selectedId)
        return {
          selectedId,
          selectedRow: selectedRow?.getAttribute('data-row-id') ?? null,
          selectedValue: selectedDatum?.body_mass_g ?? null,
          announcement: selectionText.textContent,
          focusedRow:
            container.ownerDocument.activeElement instanceof HTMLElement
              ? (container.ownerDocument.activeElement.dataset.rowSelect ??
                null)
              : null,
          focusedControl:
            container.ownerDocument.activeElement === clearButton
              ? 'clear-selection'
              : null,
          selectedOverlayCount: chartSurface.querySelectorAll(
            '.ts-chart__dot[data-ts-key="selected-observation"] circle',
          ).length,
        }
      },
    },
    destroy() {
      host.destroy()
      view.remove()
    },
  }
}

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/82-chart-table-selection/model.ts56 lines · support
cases/82-chart-table-selection/model.ts
import type { PenguinsRow } from '@charts-poc/demo-data/penguins'

export type CompletePenguin = PenguinsRow & {
  readonly culmen_length_mm: number
  readonly culmen_depth_mm: number
  readonly flipper_length_mm: number
  readonly body_mass_g: number
  readonly sex: string
}

export const selectionIds = [
  'adelie-torgersen-male',
  'adelie-biscoe-female',
  'adelie-dream-female',
  'chinstrap-dream-male',
  'gentoo-biscoe-male',
] as const

export type SelectionId = (typeof selectionIds)[number]

export function isSelectionId(value: unknown): value is SelectionId {
  return selectionIds.some((id) => id === value)
}

export function penguinSelectionId(row: PenguinsRow): SelectionId | null {
  const key = `${row.species}-${row.island}-${row.sex}`.toLowerCase()
  return isSelectionId(key) ? key : null
}

export function penguinSelectionLabel(row: CompletePenguin) {
  return `${row.species} ${row.sex.toLowerCase()} on ${row.island}`
}

export function selectionRows(
  rows: readonly PenguinsRow[],
  revision = 0,
): readonly CompletePenguin[] {
  const representatives = selectionIds.flatMap((id) => {
    const row = rows.find(
      (row): row is CompletePenguin =>
        isCompletePenguin(row) && penguinSelectionId(row) === id,
    )
    return row ? [row] : []
  })
  return revision % 2 === 1 ? representatives.reverse() : representatives
}

function isCompletePenguin(row: PenguinsRow): row is CompletePenguin {
  return (
    row.culmen_length_mm !== null &&
    row.culmen_depth_mm !== null &&
    row.flipper_length_mm !== null &&
    row.body_mass_g !== null &&
    row.sex !== null
  )
}