Skip to contentVibraUI
Charts

Bubble chart

Proportional packed circles for a handful of segments, labelled where they fit.

No recharts: it is a packing problem and an SVG. Area carries the value, so a radius goes as its square root; the largest segment sits at the centre and each next one takes the first free spot on an outward Vogel spiral, so nothing ever overlaps — packBubbles is exported and the test measures every pair against the sum of its radii. Fills are §3.8's flat 10% with the hue itself on the outline at 1.5px, so a label can be written inside in the reader's own ink rather than in white on a mid-tone. A label is printed inside its bubble when it fits there, with the value under it when there is room for two lines; the segments too small to carry one are named in a legend underneath instead, so nothing is ever left to a colour swatch alone. Colour is a meaning or a hue: a ChartTone resolves to --chart-positive and its siblings, a chart token to that token, and a segment that names neither takes the next palette slot. The plot is one image with a summary to a screen reader, and every segment is repeated as text beneath it. Best under about eight segments — past that the circles stop being comparable and the answer is a bar chart.

Install

npx shadcn@latest add @vibra/bubble-chart

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

Examples

Props

PropTypeDefaultDescription
data{ key: string; label: string; value: number; color?: ChartTone | ChartToken }[]—The segments. They are sorted largest first before anything is drawn.
valueFormatter(n: number) => stringthousands-separated numberFormats every number: the labels, the legend, the tooltips and the text rows.
heightnumber280The plot's height in pixels.
packBubbles(radii: number[], gap?: number) => { x: number; y: number }[]—The layout on its own: radii largest first, positions in the same units.

Dependencies

Source

components/ui/bubble-chart.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { CHART_TONES, type ChartTone } from "@/components/ui/chart-core"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"

/** The gap between two bubbles, as a share of the largest one's radius. */
const GAP = 0.07

/** The smallest bubble, as a share of the largest, so a thin segment stays visible. */
const MIN_SHARE = 0.12

// A Vogel spiral samples a disc evenly, so walking out along it and stopping at
// the first free spot packs from the middle without ever tunnelling through a
// bubble that is already down.
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
const SPIRAL_STEP = 0.06
const SPIRAL_LIMIT = 12_000

/** §3.8's fill: a tenth of the hue, with the hue itself carrying the outline. */
const FILL = 10
const STROKE_WIDTH = 1.5

const LABEL_SIZE = 12
const VALUE_SIZE = 11
/** Geist's average advance is a little over half its size; near enough to fit text. */
const CHAR_WIDTH = 0.55
const LABEL_PADDING = 10

/** What a plot measures before its box has been measured, so it is never empty. */
const FALLBACK_WIDTH = 320

/**
 * Math.cos, Math.sin and Math.hypot are not required to agree to the last bit
 * across engines, and the server and the browser are two engines: an
 * unrounded coordinate came out 77.53804208940596 on one and ...98 on the
 * other, which React reports as a hydration mismatch. Rounding the layout well
 * below a pixel makes the two agree without moving anything.
 */
const PLACE_PRECISION = 1e6
const DRAW_PRECISION = 100

const settle = (value: number, precision: number) => Math.round(value * precision) / precision

export type BubbleChartDatum = {
  key: string
  label: string
  value: number
  /** A meaning, or one of the eight palette hues. Defaults to the palette in order. */
  color?: ChartTone | ChartToken
}

export type BubbleChartProps = React.ComponentProps<"div"> & {
  data: BubbleChartDatum[]
  valueFormatter?: (n: number) => string
  height?: number
}

const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

/**
 * Where each bubble goes, in the same units as `radii`: the first at the origin,
 * every other one at the first point on an outward spiral where it touches
 * nothing already placed. Feed it radii largest first — a big bubble looking for
 * room among small ones lands much further out than the other way round.
 */
export function packBubbles(radii: number[], gap = GAP): { x: number; y: number }[] {
  const placed: { x: number; y: number; r: number }[] = []
  // How many bubbles have already fallen through to the fallback below, so
  // each one gets its own angle rather than all of them landing on one ray.
  let overflow = 0

  for (const r of radii) {
    if (placed.length === 0) {
      placed.push({ x: 0, y: 0, r })
      continue
    }

    let spot: { x: number; y: number } | undefined
    for (let step = 1; step <= SPIRAL_LIMIT && !spot; step++) {
      const distance = SPIRAL_STEP * Math.sqrt(step)
      const angle = step * GOLDEN_ANGLE
      const x = settle(Math.cos(angle) * distance, PLACE_PRECISION)
      const y = settle(Math.sin(angle) * distance, PLACE_PRECISION)
      const clear = placed.every(
        (other) => Math.hypot(other.x - x, other.y - y) >= other.r + r + gap
      )
      if (clear) spot = { x, y }
    }

    if (spot) {
      placed.push({ ...spot, r })
      continue
    }

    // The spiral covers a disc wide enough for almost any set, but a crowd of
    // similar-sized bubbles can still exhaust it. Past its end, the only spot
    // guaranteed clear of everything already down is beyond the cluster's own
    // outer edge — the farthest any placed bubble reaches from the origin,
    // plus this one's own radius and the gap — so that is where this bubble
    // goes; each one that lands here gets a further turn than the last so a
    // run of them fans out round the cluster instead of stacking on one ray.
    overflow += 1
    const clusterRadius = Math.max(0, ...placed.map((other) => Math.hypot(other.x, other.y) + other.r))
    const distance = clusterRadius + r + gap
    const angle = overflow * GOLDEN_ANGLE
    placed.push({
      x: settle(Math.cos(angle) * distance, PLACE_PRECISION),
      y: settle(Math.sin(angle) * distance, PLACE_PRECISION),
      r,
    })
  }

  return placed.map(({ x, y }) => ({ x, y }))
}

/** The paint a segment takes: a named meaning, a named hue, or the next palette slot. */
function bubblePaint(color: BubbleChartDatum["color"], index: number): string {
  if (color === undefined) return `var(--chart-${(index % 8) + 1})`
  if (isChartToken(color)) return `var(--${color})`
  return CHART_TONES[color as ChartTone] ?? `var(--chart-${(index % 8) + 1})`
}

const textWidth = (text: string, size: number) => text.length * size * CHAR_WIDTH

type Bubble = BubbleChartDatum & { x: number; y: number; r: number; paint: string }

/**
 * Proportional packed circles for a handful of segments.
 *
 * Area carries the value, so a radius goes as its square root; the largest sits
 * at the centre and the rest spiral out from it, and no two ever overlap. A
 * label is printed inside its bubble when it fits there — a direct label beats a
 * key every time — and the segments too small to carry one are named in a legend
 * underneath instead, so nothing is left to a colour swatch alone.
 */
function BubbleChart({
  className,
  data,
  valueFormatter = DEFAULT_FORMAT,
  height = 280,
  "aria-label": ariaLabel,
  ...props
}: BubbleChartProps) {
  const [width, setWidth] = React.useState(0)

  const measure = React.useCallback((node: HTMLDivElement | null) => {
    if (!node) return
    const read = () => setWidth(Math.round(node.getBoundingClientRect().width))
    read()
    if (typeof ResizeObserver !== "function") return
    const observer = new ResizeObserver(read)
    observer.observe(node)
    return () => observer.disconnect()
  }, [])

  // An environment with no layout engine reports 0 forever, and a chart nobody
  // ever draws is worse than one drawn at a sensible default.
  const box = width > 0 ? width : FALLBACK_WIDTH

  // The packing itself — the spiral search that costs real time for a large
  // set — depends only on the values, never on the box: a resize fires this
  // component on every frame the plot is dragged wider, and re-walking the
  // spiral on each of them would be work the reader never asked for.
  const layout = React.useMemo(() => {
    const sorted = [...data].sort((a, b) => b.value - a.value)
    const peak = Math.max(0, ...sorted.map((datum) => datum.value))
    if (peak <= 0) return { sorted, units: [] as number[], points: [] as { x: number; y: number }[] }

    const units = sorted.map((datum) =>
      Math.max(MIN_SHARE, Math.sqrt(Math.max(0, datum.value) / peak))
    )
    return { sorted, units, points: packBubbles(units) }
  }, [data])

  // Everything past the packing is cheap scale-and-centre arithmetic, so this
  // is the memo that is expected to re-run on every box/height change.
  const bubbles = React.useMemo<Bubble[]>(() => {
    const { sorted, units, points } = layout
    if (points.length === 0) return []

    const left = Math.min(...points.map((point, i) => point.x - units[i]))
    const right = Math.max(...points.map((point, i) => point.x + units[i]))
    const top = Math.min(...points.map((point, i) => point.y - units[i]))
    const bottom = Math.max(...points.map((point, i) => point.y + units[i]))
    // Fit the cluster's own box into the plot, keeping one scale on both axes so
    // the circles stay circles.
    const scale = Math.min(box / (right - left), height / (bottom - top))

    return sorted.map((datum, i) => ({
      ...datum,
      x: settle((points[i].x - left) * scale + (box - (right - left) * scale) / 2, DRAW_PRECISION),
      y: settle((points[i].y - top) * scale + (height - (bottom - top) * scale) / 2, DRAW_PRECISION),
      r: settle(units[i] * scale - STROKE_WIDTH, DRAW_PRECISION),
      paint: bubblePaint(datum.color, i),
    }))
  }, [layout, box, height])

  const outside = bubbles.filter(
    (bubble) => textWidth(bubble.label, LABEL_SIZE) + LABEL_PADDING > 2 * bubble.r
  )
  const summary =
    ariaLabel ??
    (bubbles.length === 0
      ? "Bubble chart with nothing to plot."
      : `Bubble chart of ${bubbles.length} segment${bubbles.length === 1 ? "" : "s"}, ${
          bubbles[0].label
        } largest at ${valueFormatter(bubbles[0].value)}.`)

  return (
    <div
      data-slot="bubble-chart"
      className={cn("flex w-full flex-col gap-3", className)}
      {...props}
    >
      <div ref={measure} className="w-full">
        <svg
          role="img"
          aria-label={summary}
          viewBox={`0 0 ${box} ${height}`}
          width={box}
          height={height}
          className="max-w-full"
        >
          {bubbles.map((bubble) => {
            const inside = !outside.includes(bubble)
            const twoLines =
              inside &&
              2 * bubble.r >= 3.4 * LABEL_SIZE &&
              textWidth(valueFormatter(bubble.value), VALUE_SIZE) + LABEL_PADDING <= 1.6 * bubble.r

            return (
              <g key={bubble.key} data-slot="bubble-chart-bubble">
                <circle
                  cx={bubble.x}
                  cy={bubble.y}
                  r={Math.max(1, bubble.r)}
                  fill={`color-mix(in oklch, ${bubble.paint} ${FILL}%, transparent)`}
                  stroke={bubble.paint}
                  strokeWidth={STROKE_WIDTH}
                >
                  <title>{`${bubble.label}: ${valueFormatter(bubble.value)}`}</title>
                </circle>
                {inside ? (
                  <text
                    data-slot="bubble-chart-label"
                    x={bubble.x}
                    y={bubble.y}
                    dy={twoLines ? -2 : LABEL_SIZE * 0.35}
                    textAnchor="middle"
                    fontSize={LABEL_SIZE}
                    fill="var(--foreground)"
                    className="font-medium"
                  >
                    {bubble.label}
                  </text>
                ) : null}
                {twoLines ? (
                  <text
                    data-slot="bubble-chart-value"
                    x={bubble.x}
                    y={bubble.y}
                    dy={VALUE_SIZE + 2}
                    textAnchor="middle"
                    fontSize={VALUE_SIZE}
                    fill="var(--muted-foreground)"
                    className="tabular-nums"
                  >
                    {valueFormatter(bubble.value)}
                  </text>
                ) : null}
              </g>
            )
          })}
        </svg>
      </div>

      {outside.length > 0 ? (
        // Hidden from the accessibility tree, not from the reader: the list
        // below names every segment, and a legend read out as well would say
        // half of them twice.
        <ul
          data-slot="bubble-chart-legend"
          aria-hidden="true"
          className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground"
        >
          {outside.map((bubble) => (
            <li key={bubble.key} className="flex items-center gap-1.5">
              <span
                className="size-2 shrink-0 rounded-full"
                style={{ backgroundColor: bubble.paint }}
              />
              <span className="text-foreground">{bubble.label}</span>
              <span className="tabular-nums">{valueFormatter(bubble.value)}</span>
            </li>
          ))}
        </ul>
      ) : null}

      {/* Every segment as text, whether it carried a label, a legend entry or
          neither: the plot itself is one image to a screen reader. */}
      <ul data-slot="bubble-chart-data" aria-label="Bubble chart data" className="sr-only">
        {bubbles.map((bubble) => (
          <li key={bubble.key}>{`${bubble.label}: ${valueFormatter(bubble.value)}`}</li>
        ))}
      </ul>
    </div>
  )
}

export { BubbleChart }
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)}`
  })
}