TanStack
Catalog

Stacked bars with band and rule cursors

interaction

255 lines · 2 files · 6.7 kB

cases/119-stacked-bar-band-cursor/example.tsx155 lines · entry
cases/119-stacked-bar-band-cursor/example.tsx
import { useMemo } from 'react'
import { defineChart } from '@tanstack/charts'
import { Chart } from '@tanstack/charts/react/core'
import { stackedCursorRowsForRevision } from './model'
import { barY, colorLegend, crosshair, stack } from '@tanstack/charts'
import { motion } from '@tanstack/charts/motion'
import { scaleBand, scaleLinear } from 'd3-scale'
import {
  stackedCursorBandInset,
  stackedCursorBarInset,
  stackedCursorCauses,
  stackedCursorColors,
  formatStackedCursorEndpoint,
  stackedCursorMaximum,
  stackedCursorPeriods,
} from './model'
import type { StackedCursorRow } from './model'
export interface ExampleProps {
  width?: number
  height?: number
  revision?: number
}

const cursorTransition = {
  type: 'spring' as const,
  stiffness: 260,
  damping: 26,
  mass: 0.72,
  restDelta: 0.02,
  restSpeed: 0.02,
}

export const createStackedCursorRenderer = () =>
  motion({
    initial: false,
    transition: {
      type: 'spring',
      stiffness: 190,
      damping: 22,
      mass: 0.85,
    },
  })

export const createExampleChart = (rows: readonly StackedCursorRow[]) =>
  defineChart(
    {
      marks: [
        crosshair<string, number>({
          id: 'stacked-cursor-band',
          x: {
            band: {
              fill: '#64748b',
              fillOpacity: 0.26,
              inset: stackedCursorBandInset,
              radius: 3,
            },
            label: {
              format: String,
              fill: 'CanvasText',
              stroke: 'Canvas',
              strokeWidth: 5,
              fontSize: 11,
              fontWeight: 700,
            },
          },
          y: false,
          motion: { transition: cursorTransition },
        }),
        barY(rows, {
          id: 'stacked-cursor-bars',
          x: 'period',
          y: 'deaths',
          z: 'cause',
          color: 'cause',
          key: 'id',
          layout: stack({ order: stackedCursorCauses }),
          inset: stackedCursorBarInset,
          radius: 2,
        }),
        crosshair<string, number>({
          id: 'stacked-cursor-rule',
          x: false,
          y: {
            stroke: '#475569',
            strokeOpacity: 0.82,
            strokeWidth: 1,
            strokeDasharray: '4 4',
            label: {
              format: formatStackedCursorEndpoint,
              fill: 'CanvasText',
              stroke: 'Canvas',
              strokeWidth: 16,
              fontSize: 11,
              fontWeight: 700,
            },
          },
          motion: { transition: cursorTransition },
        }),
      ],
      scales: {
        x: {
          scale: scaleBand<string>().domain(stackedCursorPeriods).padding(0.18),
        },
        y: {
          scale: scaleLinear().domain([0, stackedCursorMaximum]),
          grid: true,
          axis: { ticks: { count: 5 }, label: 'Deaths' },
        },
      },

      color: {
        domain: stackedCursorCauses,
        range: stackedCursorColors,
        legend: colorLegend({ label: 'Cause' }),
      },
      focus: 'group-x',
      focusRing: false,
      maxFocusDistance: Number.POSITIVE_INFINITY,
      tooltip: false,
      keyboard: true,
    },
    { svgAnimation: false },
  )

export default function StackedCursorCatalogView({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const renderer = useMemo(createStackedCursorRenderer, [])

  const definition = useMemo(
    () => createExampleChart(stackedCursorRowsForRevision(revision)),
    [revision],
  )

  const ariaLabel = 'Crimean War deaths with x band and y rule cursors'

  const ariaDescription =
    'Move over a stacked bar. The x cursor highlights the full stack and the dotted y cursor marks the focused segment endpoint.'

  return (
    <div data-conformance-view="main" style={{ width, height }}>
      <Chart
        idPrefix="119-stacked-bar-band-cursor"
        definition={definition}
        renderer={renderer}
        width={width}
        height={height}
        ariaLabel={ariaLabel}
        ariaDescription={ariaDescription}
      />
    </div>
  )
}
cases/119-stacked-bar-band-cursor/model.ts100 lines · dependency
cases/119-stacked-bar-band-cursor/model.ts
import { crimeanWar } from '@tanstack/charts-data/crimean-war'

export const stackedCursorCauses = ['disease', 'wounds', 'other'] as const
export const stackedCursorColors = ['#4269d0', '#ff725c', '#efb118']
export const stackedCursorBarInset = 4
export const stackedCursorOutset = 4
export const stackedCursorBandInset =
  stackedCursorBarInset - stackedCursorOutset

export type StackedCursorCause = (typeof stackedCursorCauses)[number]

export interface StackedCursorRow {
  id: string
  period: string
  cause: StackedCursorCause
  deaths: number
  start: number
  end: number
}

export interface StackedCursorBandRow {
  period: string
  total: number
}

export type StackedCursorDatum = StackedCursorRow | StackedCursorBandRow

const month = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  timeZone: 'UTC',
})
const integer = new Intl.NumberFormat('en-US')

export function formatStackedCursorEndpoint(value: number) {
  return integer.format(value)
}

const sourceRows = crimeanWar.slice(3, 11)

const revisedMultipliers = [
  [0.72, 1.5, 1.22],
  [1.2, 0.65, 0.82],
  [0.78, 1.3, 1.18],
  [1.18, 0.72, 0.86],
  [0.74, 1.28, 1.22],
  [1.15, 0.7, 0.86],
  [0.72, 1.35, 1.15],
  [1.18, 0.68, 0.84],
] as const

export const stackedCursorPeriods = sourceRows.map((row) =>
  month.format(row.date),
)

const stackedCursorRevisions = [false, true].map((revised) =>
  sourceRows.flatMap((row, periodIndex) => {
    const period = month.format(row.date)
    let start = 0
    return stackedCursorCauses.map((cause, causeIndex) => {
      const multiplier = revised
        ? (revisedMultipliers[periodIndex]?.[causeIndex] ?? 1)
        : 1
      const deaths = Math.round(row[cause] * multiplier)
      const result = {
        id: `${period}:${cause}`,
        period,
        cause,
        deaths,
        start,
        end: start + deaths,
      }
      start = result.end
      return result
    })
  }),
)

export function stackedCursorRowsForRevision(
  revision: number,
): readonly StackedCursorRow[] {
  const index = Number.isFinite(revision)
    ? Math.abs(Math.trunc(revision)) % stackedCursorRevisions.length
    : 0
  return stackedCursorRevisions[index] ?? stackedCursorRevisions[0]!
}

export const stackedCursorRows = stackedCursorRowsForRevision(0)

export const stackedCursorMaximum =
  Math.ceil(
    Math.max(
      ...stackedCursorRevisions.flatMap((rows) => rows.map((row) => row.end)),
    ) / 500,
  ) * 500

export const stackedCursorBands: readonly StackedCursorBandRow[] =
  stackedCursorPeriods.map((period) => ({
    period,
    total: stackedCursorMaximum,
  }))