TanStack
Catalog

Interactive series legend

interaction

195 lines · 2 files · 5.5 kB

cases/81-recharts-interactive-legend/example.tsx125 lines · entry
cases/81-recharts-interactive-legend/example.tsx
import { useMemo, useState } from 'react'
import { colorLegend, defineChart, lineY } from '@tanstack/charts'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { interactiveColorLegend } from '@tanstack/charts/legend'
import { Chart } from '@tanstack/charts/react'
import { industries } from '@tanstack/charts-data/industries'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { legendRows, legendSeries } from './model'
import type { LegendSeriesId } from './model'

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

export const yDomain = [0, 900] as const
export const initialVisibleSeries: readonly LegendSeriesId[] = [
  'Manufacturing',
  'Construction',
]
export const seriesColors: Readonly<Record<LegendSeriesId, string>> = {
  Manufacturing: '#2563eb',
  Construction: '#f97316',
}

export function interactiveLegendDefinition(
  revision: number,
  visibleSeries: readonly LegendSeriesId[],
  onVisibleSeriesChange: (visible: readonly LegendSeriesId[]) => void,
) {
  const rows = legendRows(industries, revision)
  return defineChart(
    {
      marks: [
        lineY(rows, {
          id: 'industry-lines',
          x: 'date',
          y: 'unemployed',
          color: 'industry',
          strokeWidth: 2.5,
        }),
      ],
      scales: {
        x: {
          scale: scaleUtc,
          axis: {
            ticks: {
              format: (date) =>
                date.toLocaleDateString('en-US', {
                  month: 'short',
                  timeZone: 'UTC',
                }),
            },
          },
        },
        y: {
          scale: scaleLinear().domain(yDomain),
          grid: true,
          axis: { ticks: { count: 5 }, label: 'Unemployed (thousands)' },
        },
      },

      color: {
        domain: legendSeries.map((series) => series.id),
        range: legendSeries.map((series) => seriesColors[series.id]),
        legend: interactiveColorLegend({
          visible: controlledSignal(visibleSeries, onVisibleSeriesChange),
          placement: 'bottom',
          ariaLabel: 'Series visibility',
        }),
      },
      margin: { top: 20, right: 24, left: 62 },
    },
    { keyboard: true },
  )
}

export default function Example({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const [visibleSeries, setVisibleSeries] = useState(initialVisibleSeries)
  const definition = useMemo(
    () =>
      interactiveLegendDefinition(revision, visibleSeries, setVisibleSeries),
    [revision, visibleSeries],
  )

  return (
    <Chart
      definition={definition}
      width={width}
      height={height}
      ariaLabel="Manufacturing and construction unemployment chart"
    />
  )
}

export function interactiveLegendPreviewDefinition(revision: number) {
  const rows = legendRows(industries, revision)
  return defineChart({
    marks: [
      lineY(rows, {
        id: 'industry-lines',
        x: 'date',
        y: 'unemployed',
        color: 'industry',
        strokeWidth: 2.5,
      }),
    ],
    scales: {
      x: { scale: scaleUtc },
      y: { scale: scaleLinear().domain(yDomain) },
    },

    color: {
      domain: legendSeries.map((series) => series.id),
      range: legendSeries.map((series) => seriesColors[series.id]),
      legend: colorLegend({ label: 'Series', placement: 'bottom' }),
    },
    margin: 0,
  })
}
cases/81-recharts-interactive-legend/model.ts70 lines · dependency
cases/81-recharts-interactive-legend/model.ts
import type { IndustriesRow } from '@tanstack/charts-data/industries'

export const legendSeries = [
  { id: 'Manufacturing', label: 'Manufacturing' },
  { id: 'Construction', label: 'Construction' },
] as const

export type LegendSeriesId = (typeof legendSeries)[number]['id']

export interface WideLegendRow {
  date: Date
  Manufacturing: number
  Construction: number
}

export function isLegendSeriesId(value: unknown): value is LegendSeriesId {
  return value === 'Manufacturing' || value === 'Construction'
}

export function toggleLegendSeries(
  visibleSeries: readonly LegendSeriesId[],
  seriesId: LegendSeriesId,
): readonly LegendSeriesId[] {
  const visible = visibleSeries.includes(seriesId)
  return legendSeries
    .map((series) => series.id)
    .filter((id) => (id === seriesId ? !visible : visibleSeries.includes(id)))
}

export function legendRows(
  rows: readonly IndustriesRow[],
  revision = 0,
): readonly IndustriesRow[] {
  const firstMonth = revision % 2 === 0 ? 0 : 6
  const lastMonth = firstMonth + 5
  return rows.filter(
    (row) =>
      row.date.getUTCFullYear() === 2000 &&
      row.date.getUTCMonth() >= firstMonth &&
      row.date.getUTCMonth() <= lastMonth &&
      isLegendSeriesId(row.industry),
  )
}

// Recharts requires one wide row per x value for multiple line series.
export function wideLegendRows(
  rows: readonly IndustriesRow[],
): readonly WideLegendRow[] {
  const grouped = new Map<number, Partial<WideLegendRow>>()
  for (const row of rows) {
    if (!isLegendSeriesId(row.industry)) continue
    const timestamp = row.date.getTime()
    const current = grouped.get(timestamp) ?? { date: row.date }
    current[row.industry] = row.unemployed
    grouped.set(timestamp, current)
  }
  return [...grouped.values()].flatMap((row) =>
    row.date &&
    row.Manufacturing !== undefined &&
    row.Construction !== undefined
      ? [
          {
            date: row.date,
            Manufacturing: row.Manufacturing,
            Construction: row.Construction,
          },
        ]
      : [],
  )
}