Source

481 chart lines · 2 files · 15.5 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/84-pinned-nested-chart-tooltip/tanstack.ts400 lines · entry
cases/84-pinned-nested-chart-tooltip/tanstack.ts
import { barY, defineChart, dot, mountChart } from '@tanstack/charts'
import { penguins } from '@charts-poc/demo-data/penguins'
import { scaleBand, scaleLinear } from 'd3-scale'
import {
  isNestedTooltipId,
  nestedTooltipRows,
  penguinCohort,
  penguinTooltipId,
  penguinTooltipLabel,
} from './model'
import type { ChartHost, ChartPoint, ChartHostOptions } from '@tanstack/charts'
import type { CompletePenguin, NestedTooltipId } from './model'
import type {
  ConformanceHandle,
  ConformanceInput,
  ConformanceTarget,
} from '../../types'

interface MiniChartInput {
  rows: readonly CompletePenguin[]
}

interface MainChartInput extends ConformanceInput {
  pinnedId: NestedTooltipId | null
}

let tooltipIdSequence = 0

const mainDefinition = (input: MainChartInput) => {
  const rows = nestedTooltipRows(penguins, input.revision)
  const selectedRows = rows.filter(
    (row) => penguinTooltipId(row) === input.pinnedId,
  )
  return defineChart({
    marks: [
      dot(rows, {
        id: 'penguins',
        x: 'flipper_length_mm',
        y: 'body_mass_g',
        r: 5,
        fill: '#2563eb',
        stroke: '#ffffff',
        strokeWidth: 1,
      }),
      ...(selectedRows.length
        ? [
            dot(selectedRows, {
              id: 'pinned-penguin',
              x: 'flipper_length_mm',
              y: 'body_mass_g',
              r: 9,
              fill: '#f97316',
              stroke: '#ffffff',
              strokeWidth: 3,
            }),
          ]
        : []),
    ],
    x: {
      scale: scaleLinear().domain([170, 235]),
      axis: { label: 'Flipper length (mm)' },
    },
    y: {
      scale: scaleLinear().domain([3000, 6000]),
      grid: true,
      axis: { ticks: { count: 5 }, label: 'Body mass (g)' },
    },
    margin: { top: 18, right: 24, bottom: 42, left: 68 },
  })
}

const miniDefinition = (input: MiniChartInput) =>
  defineChart({
    marks: [
      barY(input.rows, {
        x: (row) => String(row.flipper_length_mm),
        y: 'body_mass_g',
        fill: '#8b5cf6',
        inset: 1,
      }),
    ],
    x: {
      scale: () => scaleBand<string>().paddingInner(0.18).paddingOuter(0.08),
    },
    y: { scale: scaleLinear, axis: false },
    margin: { top: 6, right: 6, bottom: 24, left: 6 },
  })

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

export function mount(
  container: HTMLElement,
  input: ConformanceInput,
): ConformanceHandle {
  const document = container.ownerDocument
  const view = document.createElement('div')
  const chartSurface = document.createElement('div')
  const tooltip = document.createElement('aside')
  const tooltipHeader = document.createElement('div')
  const tooltipTitle = document.createElement('strong')
  const closeButton = document.createElement('button')
  const miniSurface = document.createElement('div')
  const historyDescription = document.createElement('div')
  const tooltipTitleId = `tanstack-nested-tooltip-${++tooltipIdSequence}`
  view.dataset.conformanceView = 'main'
  view.setAttribute('role', 'region')
  view.setAttribute(
    'aria-label',
    'Penguin measurements with a pinned nested-chart tooltip',
  )
  Object.assign(view.style, {
    position: 'relative',
    width: `${input.width}px`,
    height: `${input.height}px`,
  })
  tooltip.dataset.externalTooltip = 'pinned'
  tooltip.setAttribute('role', 'dialog')
  tooltip.setAttribute('aria-modal', 'false')
  tooltipTitle.id = tooltipTitleId
  tooltip.setAttribute('aria-labelledby', tooltipTitleId)
  tooltip.tabIndex = -1
  Object.assign(tooltip.style, {
    position: 'absolute',
    zIndex: '2',
    boxSizing: 'border-box',
    width: '224px',
    padding: '8px',
    border: '1px solid rgb(100 116 139 / 0.35)',
    borderRadius: '8px',
    background: 'Canvas',
    color: 'CanvasText',
    boxShadow: '0 8px 28px rgb(15 23 42 / 0.16)',
    font: '600 12px/1.3 system-ui, sans-serif',
    pointerEvents: 'auto',
  })
  Object.assign(tooltipHeader.style, {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'space-between',
    gap: '8px',
    minHeight: '44px',
  })
  closeButton.type = 'button'
  closeButton.textContent = '×'
  closeButton.setAttribute('aria-label', 'Close pinned penguin details')
  Object.assign(closeButton.style, {
    width: '44px',
    minWidth: '44px',
    height: '44px',
    flex: '0 0 44px',
    padding: '0',
    border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)',
    borderRadius: '6px',
    background: 'Canvas',
    color: 'CanvasText',
    cursor: 'pointer',
    font: '700 20px/1 system-ui, sans-serif',
  })
  miniSurface.style.width = '208px'
  miniSurface.style.height = '106px'
  historyDescription.style.position = 'absolute'
  historyDescription.style.width = '1px'
  historyDescription.style.height = '1px'
  historyDescription.style.overflow = 'hidden'
  historyDescription.style.clipPath = 'inset(50%)'
  tooltipHeader.append(tooltipTitle, closeButton)
  tooltip.append(tooltipHeader, miniSurface, historyDescription)
  tooltip.hidden = true
  view.append(chartSurface, tooltip)
  container.append(view)

  let currentInput = input
  let hoveredId: NestedTooltipId | null = null
  let pinnedId: NestedTooltipId | null = null
  let miniHost: ChartHost<CompletePenguin> | undefined

  const narrowLayout = () => currentInput.width < 520
  const panelHeight = () =>
    Math.max(96, Math.min(154, Math.round(currentInput.height * 0.42)))
  const mainHeight = () =>
    narrowLayout() && pinnedId
      ? Math.max(1, currentInput.height - panelHeight() - 8)
      : currentInput.height
  const miniDimensions = () => ({
    width: narrowLayout() ? Math.max(1, currentInput.width - 32) : 208,
    height: narrowLayout() ? Math.max(48, panelHeight() - 60) : 106,
  })

  const miniOptions = (
    rows: readonly CompletePenguin[],
  ): ChartHostOptions<CompletePenguin> => ({
    definition: defineChart(miniDefinition({ rows }), {
      animate: false,
      keyboard: false,
    }),
    ...miniDimensions(),
    ariaLabel: 'Body mass for nearby penguins of the same species',
    ariaDescription: rows
      .map(
        (row) =>
          `${row.flipper_length_mm} millimeter flipper: ${row.body_mass_g} grams`,
      )
      .join('. '),
  })

  const renderTooltip = () => {
    const datum = nestedTooltipRows(penguins, currentInput.revision).find(
      (row) => penguinTooltipId(row) === pinnedId,
    )
    if (!datum) {
      miniHost?.destroy()
      miniHost = undefined
      tooltip.hidden = true
      miniSurface.replaceChildren()
      return
    }

    const cohort = penguinCohort(penguins, datum)
    tooltipTitle.textContent = `${penguinTooltipLabel(datum)}: ${datum.body_mass_g.toLocaleString()} g`
    historyDescription.textContent = cohort
      .map(
        (row) =>
          `${row.flipper_length_mm} millimeter flipper: ${row.body_mass_g} grams`,
      )
      .join('. ')
    tooltip.hidden = false
    const dimensions = miniDimensions()
    miniSurface.style.width = `${dimensions.width}px`
    miniSurface.style.height = `${dimensions.height}px`
    if (miniHost) miniHost.update(miniOptions(cohort))
    else miniHost = mountChart(miniSurface, miniOptions(cohort))
    const id = penguinTooltipId(datum)
    if (id) placeTooltip(id)
  }

  let mainHost: ChartHost<CompletePenguin>
  const mainOptions = (): ChartHostOptions<CompletePenguin> => ({
    definition: defineChart(mainDefinition({ ...currentInput, pinnedId }), {
      animate: false,
      keyboard: true,
    }),
    width: currentInput.width,
    height: mainHeight(),
    ariaLabel: 'Selectable penguin measurement chart',
    ariaDescription:
      'Use arrow keys to choose a penguin and Enter or Space to pin a same-species comparison.',
    onFocusChange(point: ChartPoint<CompletePenguin> | null) {
      hoveredId = point ? penguinTooltipId(point.datum) : null
    },
    onSelect(point: ChartPoint<CompletePenguin> | null) {
      const selectedId = point ? penguinTooltipId(point.datum) : null
      const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
      if (svg) delete svg.dataset.restoredPoint
      pinnedId = pinnedId === selectedId ? null : selectedId
      mainHost.update(mainOptions())
      renderTooltip()
    },
  })

  mainHost = mountChart(chartSurface, mainOptions())

  const closePinned = () => {
    if (pinnedId === null) return
    const invokingId = pinnedId
    pinnedId = null
    mainHost.update(mainOptions())
    renderTooltip()
    const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
    if (svg) {
      svg.dataset.restoredPoint = invokingId
      svg.focus()
    }
  }
  const handleKeyDown = (event: KeyboardEvent) => {
    if (event.key !== 'Escape') return
    event.stopPropagation()
    closePinned()
  }
  const stopClosePointer = (event: PointerEvent) => event.stopPropagation()
  closeButton.addEventListener('click', closePinned)
  closeButton.addEventListener('pointerdown', stopClosePointer)
  view.addEventListener('keydown', handleKeyDown)

  return {
    update(nextInput) {
      currentInput = nextInput
      view.style.width = `${nextInput.width}px`
      view.style.height = `${nextInput.height}px`
      mainHost.update(mainOptions())
      renderTooltip()
    },
    driver: {
      resolveTarget(target) {
        if (target.anchor === 'tooltip:close' && !tooltip.hidden) {
          return center(closeButton)
        }
        const pointId = pointFromTarget(target)
        if (!pointId) return null
        const scene = mainHost.getScene()
        const point = scene.points.find(
          (candidate) =>
            candidate.markId === 'penguins' &&
            penguinTooltipId(candidate.datum) === pointId,
        )
        const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
        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,
        }
      },
      readState() {
        const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
        return {
          hoveredId,
          focusedPoint:
            svg && document.activeElement === svg
              ? (svg.dataset.restoredPoint ?? null)
              : null,
          tooltip: {
            visible: !tooltip.hidden,
            pinnedId,
            miniBarCount: tooltip.hidden
              ? 0
              : miniSurface.querySelectorAll('.ts-chart__bar rect').length,
            chartCount: tooltip.hidden
              ? 0
              : miniSurface.querySelectorAll('svg.ts-chart').length,
            selectedOverlayCount: chartSurface.querySelectorAll(
              '.ts-chart__dot[data-ts-key="pinned-penguin"] circle',
            ).length,
            flipperLabelCount: tooltip.hidden
              ? 0
              : miniSurface.querySelectorAll('[data-ts-key^="x-tick-label:"]')
                  .length,
            placement: tooltip.dataset.placement ?? null,
            closeVisible: !tooltip.hidden && !closeButton.hidden,
          },
        }
      },
    },
    destroy() {
      view.removeEventListener('keydown', handleKeyDown)
      closeButton.removeEventListener('click', closePinned)
      closeButton.removeEventListener('pointerdown', stopClosePointer)
      miniHost?.destroy()
      mainHost.destroy()
      view.remove()
    },
  }

  function placeTooltip(id: NestedTooltipId) {
    if (narrowLayout()) {
      tooltip.dataset.placement = 'panel'
      tooltip.style.left = '8px'
      tooltip.style.top = `${mainHeight() + 4}px`
      tooltip.style.width = `${Math.max(1, currentInput.width - 16)}px`
      tooltip.style.height = `${panelHeight()}px`
      return
    }

    const scene = mainHost.getScene()
    const point = scene.points.find(
      (candidate) =>
        candidate.markId === 'penguins' &&
        penguinTooltipId(candidate.datum) === id,
    )
    if (!point) return
    const edge = 8
    const gap = 14
    const width = tooltip.offsetWidth || 224
    const height = tooltip.offsetHeight || 150
    const placeRight = point.x + gap + width <= currentInput.width - edge
    const left = placeRight ? point.x + gap : point.x - gap - width
    const top = Math.max(
      edge,
      Math.min(currentInput.height - height - edge, point.y - height / 2),
    )
    tooltip.dataset.placement = placeRight ? 'right' : 'left'
    tooltip.style.left = `${Math.max(edge, left)}px`
    tooltip.style.top = `${top}px`
    tooltip.style.width = '224px'
    tooltip.style.height = 'auto'
  }
}

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/84-pinned-nested-chart-tooltip/model.ts81 lines · support
cases/84-pinned-nested-chart-tooltip/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 nestedTooltipIds = [
  'adelie-torgersen-male',
  'adelie-biscoe-female',
  'adelie-dream-female',
  'chinstrap-dream-male',
  'gentoo-biscoe-male',
] as const

export type NestedTooltipId = (typeof nestedTooltipIds)[number]

export function isNestedTooltipId(value: unknown): value is NestedTooltipId {
  return nestedTooltipIds.some((id) => id === value)
}

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

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

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

export function penguinCohort(
  rows: readonly PenguinsRow[],
  datum: CompletePenguin,
): readonly CompletePenguin[] {
  const nearest = rows
    .filter(
      (row): row is CompletePenguin =>
        isCompletePenguin(row) && row.species === datum.species,
    )
    .sort(
      (a, b) =>
        Math.abs(a.flipper_length_mm - datum.flipper_length_mm) -
        Math.abs(b.flipper_length_mm - datum.flipper_length_mm),
    )
  const distinctLengths = new Set<number>()
  return nearest
    .filter((row) => {
      if (distinctLengths.has(row.flipper_length_mm)) return false
      distinctLengths.add(row.flipper_length_mm)
      return true
    })
    .slice(0, 4)
    .sort((a, b) => a.flipper_length_mm - b.flipper_length_mm)
}

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