Skip to contentVibraUI
Charts

Box plot

Spreads side by side: quartiles, whiskers at 1.5 × IQR and the outliers past them, from samples or a summary.

Server-compatible: no hooks, no client boundary, no recharts. Nothing of recharts runs, but an install carries it: the shared scale reads chart-core, whose type import of chart makes the item depend on it. For comparing spreads — latency by region, order value by channel — where a mean alone hides the tail. It is the wrong chart for one group's shape in detail, which a histogram shows, and for a handful of samples a group, where the points themselves say more than five numbers drawn from them. A row per group on one shared scale: the box from the first quartile to the third as a 10% fill of the row's chart slot with a ring in it, the median a 2px ink rule across it, whiskers out to the furthest samples within 1.5 × the interquartile range, every sample past them a hollow dot, and with showMean the mean as an ink diamond. Pass raw values and summarize works the box out — quartiles as R's type 7, so they agree with a notebook, and a sample that is not a finite number ignored — or a summary worked out elsewhere, a warehouse's five numbers for a million orders. The scale takes zero in and runs on nice steps over what is drawn: domain fixes it, to share one scale between two plots or to fit a range far from zero, and showOutliers={false} stops it reaching for a far point. A track too narrow for every tick label prints only the two ends, the gridlines still marking the steps between. One image named by its label and summary; each row's reading is in a visually hidden list beside it, a group with no samples says so, and hovering a row quiets the others.

Install

npx shadcn@latest add @vibra/box-plot

Needs the @vibra registry in your components.json — set it up once.

Examples

From summaries, with the mean

Props

PropTypeDefaultDescription
data({ label: string; values: number[] } | { label: string; summary: BoxSummary })[]—One row per group, from raw samples or from a summary of { min, q1, median, q3, max, mean?, outliers? }. Rows take the chart slots in order, grey first.
valueFormatter(n: number) => stringnumber with up to two decimalsFormats every number the plot prints: the ticks, the summary and the rows.
showOutliersbooleantrueDraws each sample past the whiskers as a hollow dot. Off, the scale stops reaching for them; the rows still count them.
showMeanbooleanfalseMarks each group's mean with an ink diamond, and says it in the rows.
domain[number, number]nice steps over what is drawn, zero taken inFixes the scale, e.g. to share one between two plots or to fit a range far from zero.
labelstring—Names the plot ahead of its summary, e.g. "Latency spread by region", and names its hidden rows.

Dependencies

Source

components/ui/box-plot.tsx
import * as React from "react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { niceDomain, niceStep } from "@/components/ui/chart-scale"
import { CHART_TOKENS, type ChartToken } from "@/components/ui/percentage-bar"

/** A distribution in five numbers, with the points past its whiskers and, optionally, its mean. */
export type BoxSummary = {
  min: number
  q1: number
  median: number
  q3: number
  max: number
  mean?: number
  outliers?: number[]
}

/** A group to draw: its raw samples, or a summary already worked out elsewhere. */
export type BoxPlotDatum = { label: string; values: number[] } | { label: string; summary: BoxSummary }

/**
 * The `p`th quantile of an ascending list, interpolated between the two
 * samples either side of it the way R's default (type 7) does, so the
 * quartiles agree with what a spreadsheet or a notebook prints.
 */
export function quantile(sorted: number[], p: number): number {
  if (sorted.length === 0) return Number.NaN
  const h = (sorted.length - 1) * Math.min(1, Math.max(0, p))
  const below = Math.floor(h)
  return sorted[below] + (h - below) * (sorted[Math.ceil(h)] - sorted[below])
}

/**
 * Samples as a box: the quartiles and the median, whiskers out to the
 * furthest samples within 1.5 × the interquartile range of the box, every
 * sample past them listed as an outlier, and the mean of them all. A sample
 * that is not a finite number is not a sample; with none left there is no
 * box to draw.
 */
export function summarize(values: number[]): BoxSummary | null {
  const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b)
  if (sorted.length === 0) return null
  const q1 = quantile(sorted, 0.25)
  const q3 = quantile(sorted, 0.75)
  const reach = 1.5 * (q3 - q1)
  const inside = (value: number) => value >= q1 - reach && value <= q3 + reach
  const whiskers = sorted.filter(inside)
  return {
    min: whiskers[0] ?? sorted[0],
    q1,
    median: quantile(sorted, 0.5),
    q3,
    max: whiskers[whiskers.length - 1] ?? sorted[sorted.length - 1],
    outliers: sorted.filter((value) => !inside(value)),
    mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length,
  }
}

const DEFAULT_FORMAT = (value: number) => formatNumber(value)

// Spelled out rather than built from `text-${token}`, because Tailwind scans
// for whole class names in the source and would find nothing to generate.
const CHART_TEXT: Record<ChartToken, string> = {
  "chart-1": "text-chart-1",
  "chart-2": "text-chart-2",
  "chart-3": "text-chart-3",
  "chart-4": "text-chart-4",
  "chart-5": "text-chart-5",
  "chart-6": "text-chart-6",
  "chart-7": "text-chart-7",
  "chart-8": "text-chart-8",
}

/** Hovering a row quiets the others, so its box and its label read as one. */
const HOVER = "[&:has([data-slot=box-plot-row]:hover)_[data-slot=box-plot-row]:not(:hover)]:opacity-45"

/** Centred on its value from either side of the page; the inline start is the low end of the scale. */
const CENTRED = "-translate-x-1/2 rtl:translate-x-1/2"
/** A hollow dot is the plane the plot sits on showing through its ring. */
const OUTLIER_FILL = "bg-[var(--chart-surface,var(--card))]"

/**
 * Below these widths the labels between the end ticks would collide, so only
 * the ends print and the gridlines carry the steps. Nothing is measured on the
 * server: `axisRoom` works the width out from the labels, a container query on
 * the axis answers it at the first step that holds it, and past the last step
 * only the ends print.
 */
const CROWDED_AXIS = [
  { below: 128, hide: "@max-[8rem]/box-axis:hidden" },
  { below: 192, hide: "@max-[12rem]/box-axis:hidden" },
  { below: 256, hide: "@max-[16rem]/box-axis:hidden" },
  { below: 320, hide: "@max-[20rem]/box-axis:hidden" },
  { below: 384, hide: "@max-[24rem]/box-axis:hidden" },
  { below: 512, hide: "@max-[32rem]/box-axis:hidden" },
  { below: 768, hide: "@max-[48rem]/box-axis:hidden" },
  { below: 1024, hide: "@max-[64rem]/box-axis:hidden" },
  { below: Number.POSITIVE_INFINITY, hide: "hidden" },
]

// A tick label's characters at the axis's 12px: a comma or a "1" about 4px, an
// "m" or a "W" 12px, anything else the widest figure's 8.25px — never short.
const NARROW = new Set(" ,.:;'|!ilj1")
const WIDE = new Set("mwMW%@")
const LABEL_GAP = 8

/** About how wide a tick label prints at the axis's 12px, never less than it does. */
export function labelWidth(text: string): number {
  let width = 0
  for (const char of text) width += NARROW.has(char) ? 4 : WIDE.has(char) ? 12 : 8.25
  return width
}

/**
 * The narrowest track, in pixels, on which no two tick labels touch: the end
 * labels are drawn inward from their ticks and the rest centred on theirs, so
 * next to an end tick the end label fits whole beside half its neighbour, with
 * a gap. `at` is each tick's place along the track, 0 to 100.
 */
export function axisRoom(ticks: { label: string; at: number }[]): number {
  let room = 0
  for (let i = 0; i + 1 < ticks.length; i++) {
    const after = labelWidth(ticks[i].label) / (i === 0 ? 1 : 2)
    const before = labelWidth(ticks[i + 1].label) / (i + 1 === ticks.length - 1 ? 1 : 2)
    const share = (ticks[i + 1].at - ticks[i].at) / 100
    if (share > 0) room = Math.max(room, (after + before + LABEL_GAP) / share)
  }
  return Math.ceil(room)
}

/** Two decimals: short styles, and the same string from the server and the browser. */
const round = (n: number) => Math.round(n * 100) / 100

const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`

/** A box in words: "Median 5.5, middle half 3.25 to 7.75, whiskers 1 to 9, 1 outlier". */
function reading(box: BoxSummary, format: (n: number) => string, showMean: boolean): string {
  const outliers = box.outliers?.length ?? 0
  return [
    `Median ${format(box.median)}`,
    `middle half ${format(box.q1)} to ${format(box.q3)}`,
    `whiskers ${format(box.min)} to ${format(box.max)}`,
    outliers === 0 ? "no outliers" : count(outliers, "outlier"),
    ...(showMean && box.mean !== undefined ? [`mean ${format(box.mean)}`] : []),
  ].join(", ")
}

/** Nice ticks inside a scale the caller fixed, rather than a scale widened out to nice ends. */
function ticksWithin([low, high]: [number, number]): number[] {
  const step = niceStep((high - low) / 4)
  const ticks: number[] = []
  for (let tick = Math.ceil(low / step - 1e-9) * step; tick <= high + step * 1e-9; tick += step) {
    ticks.push(Number(tick.toPrecision(12)))
  }
  return ticks
}

export type BoxPlotProps = Omit<React.ComponentProps<"div">, "children"> & {
  data: BoxPlotDatum[]
  /** Formats every number the plot prints: the ticks, the summary and the rows. */
  valueFormatter?: (n: number) => string
  /** Draws each sample past the whiskers as a hollow dot. Off, the scale stops reaching for them. */
  showOutliers?: boolean
  /** Marks each group's mean with an ink diamond, and says it in the rows. */
  showMean?: boolean
  /** Fixes the scale, e.g. to share it with another plot. Defaults to nice steps over what is drawn. */
  domain?: [number, number]
  /** Names the plot ahead of its summary, e.g. "Latency by region". */
  label?: string
}

function BoxPlot({
  className,
  data,
  valueFormatter = DEFAULT_FORMAT,
  showOutliers = true,
  showMean = false,
  domain,
  label,
  ...props
}: BoxPlotProps) {
  const rows = data.map((datum, i) => ({
    label: datum.label,
    box: "values" in datum ? summarize(datum.values) : datum.summary,
    paint: CHART_TEXT[CHART_TOKENS[i % CHART_TOKENS.length]],
  }))
  const boxes = rows.flatMap((row) => (row.box ? [row.box] : []))

  // One scale for every row: what is drawn, out to nice steps either side.
  const drawn = boxes.flatMap((box) => [
    box.min,
    box.max,
    ...(showOutliers ? (box.outliers ?? []) : []),
    ...(showMean && box.mean !== undefined ? [box.mean] : []),
  ])
  const scale = domain
    ? { domain, ticks: ticksWithin(domain) }
    : niceDomain(Math.min(0, ...drawn), Math.max(0, ...drawn), 4)
  const [low, high] = scale.domain
  const span = high - low || 1
  const at = (value: number) => round(Math.min(100, Math.max(0, ((value - low) / span) * 100)))
  const room = axisRoom(scale.ticks.map((tick) => ({ label: valueFormatter(tick), at: at(tick) })))
  const crowded = CROWDED_AXIS.find((width) => width.below >= room)!.hide

  const medians = boxes.map((box) => box.median)
  const summary =
    data.length === 0
      ? "Box plot with no data."
      : medians.length === 0
        ? `Box plot of ${count(data.length, "group")}, no samples.`
        : data.length === 1
          ? `Box plot of 1 group, median ${valueFormatter(medians[0])}.`
          : `Box plot of ${count(data.length, "group")}, medians from ${valueFormatter(Math.min(...medians))} to ${valueFormatter(Math.max(...medians))}.`

  return (
    <div
      data-slot="box-plot"
      data-outliers={showOutliers || undefined}
      data-mean={showMean || undefined}
      className={cn("w-full", className)}
      {...props}
    >
      <div
        role="img"
        aria-label={label ? `${label}. ${summary}` : summary}
        className={cn("grid grid-cols-[fit-content(40%)_minmax(0,1fr)] items-center gap-x-3 gap-y-2", HOVER)}
      >
        {rows.length > 0 ? (
          // Gridlines behind every row, gaps and all: one scale for them all.
          <div className="pointer-events-none relative col-start-2 self-stretch" style={{ gridRow: `1 / span ${rows.length}` }}>
            {scale.ticks.map((tick) => (
              <span
                key={tick}
                className="absolute inset-y-0 border-s border-dashed border-chart-grid"
                style={{ insetInlineStart: `${at(tick)}%` }}
              />
            ))}
          </div>
        ) : null}

        {rows.map((row, i) => (
          <div
            key={`${row.label}-${i}`}
            data-slot="box-plot-row"
            className="col-span-full grid grid-cols-subgrid items-center transition-opacity duration-(--duration-base) ease-(--ease-standard)"
            style={{ gridRow: i + 1 }}
          >
            <span data-slot="box-plot-label" className="truncate text-xs text-muted-foreground">
              {row.label}
            </span>
            <div data-slot="box-plot-track" className={cn("relative h-6", row.paint)}>
              {row.box ? (
                <>
                  {[[row.box.min, row.box.q1], [row.box.q3, row.box.max]].map(([from, to], w) => (
                    <span
                      key={w}
                      data-slot="box-plot-whisker"
                      className="absolute top-1/2 h-px -translate-y-1/2 bg-current"
                      style={{ insetInlineStart: `${at(from)}%`, width: `${round(at(to) - at(from))}%` }}
                    />
                  ))}
                  {[row.box.min, row.box.max].map((end, e) => (
                    <span
                      key={e}
                      className={cn("absolute inset-y-1.5 w-px bg-current", CENTRED)}
                      style={{ insetInlineStart: `${at(end)}%` }}
                    />
                  ))}
                  <span
                    data-slot="box-plot-box"
                    className="absolute inset-y-0.5 rounded-[3px] border border-current bg-current/10"
                    style={{ insetInlineStart: `${at(row.box.q1)}%`, width: `${round(at(row.box.q3) - at(row.box.q1))}%` }}
                  />
                  <span
                    data-slot="box-plot-median"
                    className={cn("absolute inset-y-0 w-0.5 bg-foreground", CENTRED)}
                    style={{ insetInlineStart: `${at(row.box.median)}%` }}
                  />
                  {showMean && row.box.mean !== undefined ? (
                    <span
                      data-slot="box-plot-mean"
                      className={cn("absolute top-1/2 size-1.5 -translate-y-1/2 rotate-45 bg-foreground ring-1 ring-card", CENTRED)}
                      style={{ insetInlineStart: `${at(row.box.mean)}%` }}
                    />
                  ) : null}
                  {showOutliers
                    ? (row.box.outliers ?? []).map((value, o) => (
                        <span
                          key={o}
                          data-slot="box-plot-outlier"
                          className={cn("absolute top-1/2 size-2 -translate-y-1/2 rounded-full border-[1.5px] border-current", OUTLIER_FILL, CENTRED)}
                          style={{ insetInlineStart: `${at(value)}%` }}
                        />
                      ))
                    : null}
                </>
              ) : (
                <span className="absolute inset-y-0 start-0 flex items-center text-xs text-muted-foreground">No samples</span>
              )}
            </div>
          </div>
        ))}

        {rows.length > 0 ? (
          <div data-slot="box-plot-axis" className="@container/box-axis relative col-start-2 h-4" style={{ gridRow: rows.length + 1 }}>
            {scale.ticks.map((tick, i) => (
              <span
                key={tick}
                data-slot="box-plot-tick"
                // The end ticks sit inside the track, so a label never hangs
                // past the edge of the card.
                className={cn(
                  "absolute top-0 text-xs whitespace-nowrap text-chart-axis tabular-nums",
                  i === scale.ticks.length - 1 ? "-translate-x-full rtl:translate-x-full" : i > 0 && cn(CENTRED, crowded)
                )}
                style={{ insetInlineStart: `${at(tick)}%` }}
              >
                {valueFormatter(tick)}
              </span>
            ))}
          </div>
        ) : null}
      </div>

      <ul data-slot="box-plot-data" aria-label={`${label ?? "Box plot"} data`} className="sr-only">
        {rows.map((row, i) => (
          <li key={`${row.label}-${i}`}>{`${row.label}: ${row.box ? reading(row.box, valueFormatter, showMean) : "No samples"}`}</li>
        ))}
      </ul>
    </div>
  )
}

export { BoxPlot }
components/ui/chart-scale.ts
import {
  annotationValues,
  type ChartAnnotation,
  type ChartDatum,
  type ChartSeries,
} from "@/components/ui/chart-core"

// A printed scale lands on steps a reader can add up in their head. Anything
// else — recharts' own 0/650/1.3K/2K/2.6K — makes the reader do arithmetic to
// place a point between two gridlines.
const NICE_STEPS = [1, 2, 2.5, 5, 10] as const

/** Rounds a rough interval up to the next 1, 2, 2.5 or 5 × 10ⁿ. */
export function niceStep(rough: number): number {
  if (!Number.isFinite(rough) || rough <= 0) return 1
  const magnitude = 10 ** Math.floor(Math.log10(rough))
  const scaled = rough / magnitude
  const step = NICE_STEPS.find((candidate) => scaled <= candidate * (1 + 1e-9)) ?? 10
  return step * magnitude
}

// Summing a float step accumulates error, so each tick is computed from the
// index and then snapped back to the precision the step itself carries.
const snap = (value: number): number => Number(value.toPrecision(12))

/**
 * Where a value axis starts. `zero` — the default everywhere — always takes
 * zero in, because a count, a sum or a share is read against nothing, and a
 * bar that does not start there lies about its length. `auto` fits the axis
 * to the data's own range, on the same nice steps, for a level whose
 * movement is the reading — a rate, a price, a temperature — and which a
 * zero floor would flatten into a line along the top of the plot.
 */
export type ChartBaseline = "zero" | "auto"

/** Ticks from `bottom` to `top` on `step`, both ends included. */
function ticksBetween(bottom: number, top: number, step: number): number[] {
  const ticks: number[] = []
  for (let i = 0; bottom + i * step <= top + step / 2; i += 1) ticks.push(snap(bottom + i * step))
  return ticks
}

/** The ticks and the domain for a range, on nice steps, both ends included. */
export function niceDomain(
  min: number,
  max: number,
  count = 4,
  options: { baseline?: ChartBaseline } = {}
): { domain: [number, number]; ticks: number[] } {
  if (options.baseline === "auto") return fittedDomain(min, max, count)

  const low = Math.min(0, Number.isFinite(min) ? min : 0)
  const high = Math.max(Number.isFinite(max) ? max : 0, low)
  // A flat series at zero still needs two gridlines, or the plot has no scale.
  if (high === low) return { domain: [low, low + 1], ticks: [low, low + 1] }

  const step = niceStep((high - low) / Math.max(1, count))
  const bottom = Math.floor(low / step + 1e-9) * step
  const top = Math.ceil(high / step - 1e-9) * step
  return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}

/**
 * The `auto` baseline: the data's own range, widened out to the nice steps
 * either side of it, so 1.05–1.11 prints 1.04 / 1.06 / … / 1.12 rather than
 * 0 / 0.5 / 1 / 1.5. A flat level still gets a scale — a step either side of
 * it — and nothing to plot falls back to the zero baseline's 0 / 1.
 *
 * A peak drawn on the frame's edge reads as clipped, so an extreme within a
 * quarter of a step of the edge gets one step more: 1.05–1.12 prints up to
 * 1.14. That air never takes the axis across zero, which a level that does
 * not cross it has no business reaching past.
 */
function fittedDomain(min: number, max: number, count: number): { domain: [number, number]; ticks: number[] } {
  const lowest = Number.isFinite(min) ? min : 0
  const highest = Number.isFinite(max) ? Math.max(max, lowest) : lowest
  let low = lowest
  let high = highest
  if (high === low) {
    if (low === 0) return niceDomain(0, 0, count)
    const pad = niceStep(Math.abs(low) / 10)
    low -= pad
    high += pad
  }

  const step = niceStep((high - low) / Math.max(1, count))
  let bottom = Math.floor(low / step + 1e-9) * step
  let top = Math.ceil(high / step - 1e-9) * step
  if (lowest - bottom < step / 4) bottom = lowest >= 0 ? Math.max(0, bottom - step) : bottom - step
  if (top - highest < step / 4) top = highest <= 0 ? Math.min(0, top + step) : top + step
  return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}

/** The printed scale from 0 to `max`: `niceTicks(2_900, 4)` is 0, 1000, 2000, 3000. */
export function niceTicks(max: number, count = 4): number[] {
  return niceDomain(0, max, count).ticks
}

/**
 * The lowest and highest number a set of series reaches, stacked or laid over
 * one another. On the zero baseline the extent always takes zero in, so a
 * hole in the data or a series of nothing reads as 0 / 0; on `auto` it is the
 * data's own range, and ±Infinity when there is nothing to read.
 */
export function chartExtent(
  data: ChartDatum[],
  series: ChartSeries[],
  options: { stacked?: boolean; compare?: boolean; baseline?: ChartBaseline } = {}
): { min: number; max: number } {
  const keys = series.flatMap((entry) =>
    options.compare && entry.compareKey ? [entry.key, entry.compareKey] : [entry.key]
  )
  const fitted = options.baseline === "auto"
  let min = fitted ? Infinity : 0
  let max = fitted ? -Infinity : 0
  for (const datum of data) {
    let stack = 0
    // A row of holes is no stack at all — not a stack of zero, which a
    // fitted axis would otherwise have to reach down to.
    let stacked = false
    for (const key of keys) {
      const raw = datum[key]
      if (typeof raw !== "number" || !Number.isFinite(raw)) continue
      if (options.stacked) {
        stack += raw
        stacked = true
      } else {
        if (raw < min) min = raw
        if (raw > max) max = raw
      }
    }
    if (stacked) {
      if (stack < min) min = stack
      if (stack > max) max = stack
    }
  }
  return { min, max }
}

/** A y axis' explicit domain and ticks for the data it has to hold. */
export function yAxisScale(
  data: ChartDatum[],
  series: ChartSeries[],
  options: {
    stacked?: boolean
    compare?: boolean
    count?: number
    annotations?: ChartAnnotation[]
    /** Where the axis starts; zero unless the chart asks to fit its data. */
    baseline?: ChartBaseline
  } = {}
): { domain: [number, number]; ticks: number[] } {
  const { min, max } = chartExtent(data, series, options)
  // A goal line above every plotted point still has to fit inside the plot.
  let low = min
  let high = max
  for (const annotation of options.annotations ?? []) {
    for (const value of annotationValues(annotation)) {
      if (value < low) low = value
      if (value > high) high = value
    }
  }
  return niceDomain(low, high, options.count ?? 4, { baseline: options.baseline })
}

// Below these the axis band costs more than the scale it prints: the labels
// crowd the plot at 120px tall and eat a third of the width at 240px wide.
const MIN_AXIS_HEIGHT = 120
const MIN_AXIS_WIDTH = 240

/**
 * Whether a y axis fits. Width 0 means "not measured yet" — on the server, and
 * in a test with no layout — and is never taken as "too narrow".
 */
export function fitsYAxis(height: number, width = 0): boolean {
  if (height < MIN_AXIS_HEIGHT) return false
  return width === 0 || width >= MIN_AXIS_WIDTH
}
components/ui/chart-core.ts
import * as React from "react"

import { formatNumber } from "@/lib/format"
import type { ChartConfig } from "@/components/ui/chart"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"

/** One point of a chart: the index value plus one number per series key. */
export type ChartDatum = Record<string, string | number | null | undefined>

/** One plotted measure — which key to read, what to call it, what to paint it. */
export type ChartSeries = {
  key: string
  label: string
  /** A chart token, or any CSS colour for a brand hue. Defaults to the palette in order. */
  color?: ChartToken | string
  /**
   * The key holding the same measure over the period before this one. Drawn as
   * a dashed ghost behind the series and printed in its tooltip row as a delta.
   */
  compareKey?: string
  /**
   * The colour the ghost is drawn in: a chart token or any CSS colour. Left
   * out, the ghost wears the series' own colour; `var(--chart-neutral)` keeps
   * the period before out of the palette, grey under every preset — including
   * one whose first slot follows a coloured accent.
   */
  compareColor?: ChartToken | string
}

/** The props every cartesian chart in the set accepts. */
export type CommonChartProps = React.ComponentProps<"div"> & {
  data: ChartDatum[]
  /** The key holding each point's category — the month, the day, the source. */
  index: string
  series: ChartSeries[]
  /** Formats every number the chart prints: axis ticks, tooltips, and the text summary. */
  valueFormatter?: (n: number) => string
  /** Formats every index label, e.g. an ISO date into "Sep 4". */
  indexFormatter?: (v: string | number) => string
  /** Plot height in pixels, axis band included. */
  height?: number
  showLegend?: boolean
  showGrid?: boolean
  showXAxis?: boolean
  showYAxis?: boolean
  showTooltip?: boolean
  /** Where the series are named: beside their last point, under the plot, or nowhere. */
  legend?: ChartLegendPlacement
  /** Goal lines, event markers and bands drawn over the plot and read out as text. */
  annotations?: ChartAnnotation[]
  /** Draws each series' `compareKey` as a dashed ghost behind it, in the series' `compareColor` if it names one, its own colour if not. */
  compare?: boolean
  className?: string
}

// The palette wraps rather than inventing a ninth hue: past eight series the
// colours stop being distinguishable, so fold the tail into an "Other" series.
const PALETTE_SIZE = 8

/**
 * A series' colour: a chart token becomes its CSS variable, any other string is
 * taken as a raw CSS colour, and a series that names none takes the next slot in
 * the palette, wrapping past the eighth.
 */
export function seriesColor(series: ChartSeries, i: number): string {
  const color = series.color
  if (color === undefined) return `var(--chart-${(i % PALETTE_SIZE) + 1})`
  return isChartToken(color) ? `var(--${color})` : color
}

/**
 * The ChartConfig the chart primitive needs — it turns each entry into a
 * `--color-<key>` custom property that recharts marks reference.
 */
export function buildChartConfig(series: ChartSeries[]): ChartConfig {
  // fromEntries defines own properties, so a series keyed "__proto__" lands as
  // data instead of reassigning the config object's prototype.
  return Object.fromEntries(
    series.map((entry, i) => [entry.key, { label: entry.label, color: seriesColor(entry, i) }])
  )
}

/**
 * The shared starting point: a 280px plot with both axes, a grid, direct labels
 * at the end of each series, and a tooltip. The value axis is on — a chart
 * without a printed scale is a shape, not a measurement — and the charts turn it
 * off themselves when the plot is too small to carry one (`fitsYAxis`).
 */
export const chartDefaults = {
  height: 280,
  showLegend: true,
  showGrid: true,
  showXAxis: true,
  showYAxis: true,
  showTooltip: true,
  legend: "inline-end",
} as const

/**
 * A drawn line: 2px with round caps and joins reads as a stroke at any size
 * and holds its own on a white sheet, where 1.5px thinned to a hair on a
 * high-density screen.
 */
export const STROKE_WIDTH = 2

/**
 * The gridline: the grid token, dashed, so a rule under the data reads as a
 * guide rather than as a border. Every cartesian chart spreads this onto its
 * CartesianGrid.
 */
export const GRID_PROPS = { stroke: "var(--chart-grid)", strokeDasharray: "3 3" } as const

/**
 * The dot under the pointer: 3.5px of the series' own colour, ringed in the
 * surface it sits on so it stays legible where two lines cross.
 * --chart-surface falls back to the card a chart normally lives on; set it on
 * any ancestor when the chart sits on another plane.
 */
export const ACTIVE_DOT = { r: 3.5, strokeWidth: 2, stroke: "var(--chart-surface, var(--card))" } as const

/**
 * The corner a column turns, and the gap between two segments of one stack. A
 * stack is rounded as one shape — the top corners on the topmost segment, the
 * bottom corners on the lowest — so a bar reads as a printed block rather than
 * a pile of separately rounded tiles.
 */
export const BAR_RADIUS = 6
export const BAR_GAP = 1

/** Axis chrome: no rule and no tick marks, so only the labels carry the scale. */
export const axisProps = { tickLine: false, axisLine: false, tickMargin: 8, fontSize: 12 } as const

/**
 * The paint every axis label takes: --chart-axis, the token §3.8 names for the
 * printed scale. recharts writes fill="#666" onto its own
 * tick text, and the primitive's `.recharts-cartesian-axis-tick text` rule does
 * not reach it — recharts 3 nests labels under
 * `.recharts-cartesian-axis-tick-labels` instead — so #666 survived on the card
 * at 3.16:1 in dark. Passing the fill as a tick prop puts it on the element
 * itself, where nothing has to match a selector.
 */
export const AXIS_TICK = { fill: "var(--chart-axis)" } as const

/** Chart numbers fall back to thousands-separated values when no valueFormatter is given. */
export function defaultValueFormatter(value: number): string {
  return formatNumber(value)
}

/** Index labels print as they arrive unless the chart is given an indexFormatter. */
export function defaultIndexFormatter(value: string | number): string {
  return String(value)
}

/** What the text helpers need to turn a chart's data back into words. */
export type ChartTextOptions = {
  data: ChartDatum[]
  index: string
  series: ChartSeries[]
  valueFormatter?: (n: number) => string
  indexFormatter?: (v: string | number) => string
}

function readIndex(datum: ChartDatum, index: string, format: (v: string | number) => string) {
  const raw = datum[index]
  return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}

function readValue(raw: ChartDatum[string], format: (n: number) => string) {
  return typeof raw === "number" && Number.isFinite(raw) ? format(raw) : "no data"
}

function joinLabels(labels: string[]): string {
  if (labels.length < 2) return labels.join("")
  return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
}

/**
 * The plotted data as text, one row per point. Every chart renders this into a
 * visually hidden list, so the numbers are readable without pointing at a tooltip.
 */
export function chartRows(options: ChartTextOptions): { label: string; readings: string }[] {
  const {
    data,
    index,
    series,
    valueFormatter = defaultValueFormatter,
    indexFormatter = defaultIndexFormatter,
  } = options

  return data.map((datum) => ({
    label: readIndex(datum, index, indexFormatter),
    readings: series
      .map((entry) => `${entry.label} ${readValue(datum[entry.key], valueFormatter)}`)
      .join(", "),
  }))
}

/**
 * The heading for a tooltip: the point's own index value, read straight off the
 * datum. The primitive resolves its label through the config, which only works
 * when the index is a string — reading the datum keeps numeric indexes intact.
 */
export function tooltipIndexLabel(
  payload: ReadonlyArray<{ payload?: unknown }> | undefined,
  index: string,
  format: (v: string | number) => string = defaultIndexFormatter
): string {
  const datum = payload?.[0]?.payload
  const raw = datum && typeof datum === "object" ? (datum as ChartDatum)[index] : undefined
  return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}

/** A one-sentence description of what a chart plots, used as its accessible name. */
export function chartSummary(kind: string, options: ChartTextOptions): string {
  const { data, index, series, indexFormatter = defaultIndexFormatter } = options
  if (series.length === 0) return `${kind} with no series.`

  const labels = joinLabels(series.map((entry) => entry.label))
  if (data.length === 0) return `${kind} of ${labels} by ${index}. No data.`

  const first = readIndex(data[0], index, indexFormatter)
  const last = readIndex(data[data.length - 1], index, indexFormatter)
  const count = `${data.length} point${data.length === 1 ? "" : "s"}`
  return `${kind} of ${labels} by ${index}, ${count} from ${first} to ${last}.`
}

/* -------------------------------------------------------------------------- */
/* Annotations                                                                 */
/* -------------------------------------------------------------------------- */

/** Where a chart names its series. */
export type ChartLegendPlacement = "inline-end" | "bottom" | "none"

/** The meanings an annotation can carry; each resolves to a token, never a literal. */
export type ChartAnnotationTone = "neutral" | "brand" | "positive" | "negative" | "warning" | "info"

export type ChartAnnotation =
  /** A threshold across the plot: a goal, a limit, an included allowance. */
  | { kind: "line"; axis?: "x" | "y"; value: number | string; label: string; tone?: ChartAnnotationTone }
  /** A moment on the index axis: a launch, a deploy, an incident. */
  | { kind: "event"; x: number | string; label: string; tone?: ChartAnnotationTone; href?: string }
  /**
   * A stretch of the index axis: a freeze, an outage, a campaign. The label
   * reads from the band's start; `align: "end"` hangs it from the band's end
   * instead, for a band that runs to the edge of the plot — a 59px word over
   * a 30px band at the right edge otherwise runs out of the plot.
   */
  | {
      kind: "band"
      from: number | string
      to: number | string
      label: string
      tone?: ChartAnnotationTone
      align?: "start" | "end"
    }

const ANNOTATION_PAINT: Record<ChartAnnotationTone, string> = {
  neutral: "var(--faint-foreground)",
  brand: "var(--brand)",
  positive: "var(--chart-positive)",
  negative: "var(--chart-negative)",
  warning: "var(--warning)",
  info: "var(--info)",
}

/**
 * The paint a *meaning* takes, as opposed to a category.
 *
 * A series that is coded by status — 200/429/500, up/down, paid/overdue — is
 * not one of eight interchangeable hues: it has to resolve to the semantic
 * tokens, or a reader learns the wrong colour for "failed" on one page and
 * carries it to the next. Categories keep the palette; meanings come from here.
 */
export const CHART_TONES = {
  positive: "var(--chart-positive)",
  negative: "var(--chart-negative)",
  neutral: "var(--chart-neutral)",
  warning: "var(--warning)",
  info: "var(--info)",
} as const

export type ChartTone = keyof typeof CHART_TONES

export function chartTone(tone: ChartTone): string {
  return CHART_TONES[tone]
}

/** The token an annotation's tone paints in. */
export function annotationPaint(tone: ChartAnnotationTone = "neutral"): string {
  return ANNOTATION_PAINT[tone] ?? ANNOTATION_PAINT.neutral
}

/** The numbers an annotation pins to the value axis, so the scale can hold them. */
export function annotationValues(annotation: ChartAnnotation): number[] {
  if (annotation.kind === "line" && annotation.axis !== "x" && typeof annotation.value === "number")
    return [annotation.value]
  return []
}

/**
 * Every annotation as a line of text, appended to a chart's visually hidden
 * rows: a goal line a sighted reader can see has to be readable too.
 */
export function annotationRows(
  annotations: ChartAnnotation[] = [],
  options: {
    valueFormatter?: (n: number) => string
    indexFormatter?: (v: string | number) => string
  } = {}
): string[] {
  const {
    valueFormatter = defaultValueFormatter,
    indexFormatter = defaultIndexFormatter,
  } = options
  const at = (value: number | string) =>
    typeof value === "number" ? valueFormatter(value) : indexFormatter(value)

  return annotations.map((annotation) => {
    if (annotation.kind === "line")
      return `${annotation.label}: ${
        annotation.axis === "x" ? indexFormatter(annotation.value) : at(annotation.value)
      }`
    if (annotation.kind === "event") return `${annotation.label} at ${indexFormatter(annotation.x)}`
    return `${annotation.label}: ${indexFormatter(annotation.from)} to ${indexFormatter(annotation.to)}`
  })
}