Skip to contentVibraUI
Charts

Sankey chart

Where a population moved between stages: flows as wide as their values, a hovered path lit.

For the same population moving between states, where it went being the reading: plans into plans and into churn, a budget into its lines. It is the wrong chart for steps one population passes through in order — that is a funnel — and for comparing sizes, which a bar chart does with less ink. Drawn on recharts' Sankey: each stage a bar as tall as what passes through it, labelled with its value before the first column and after every other, each flow a band in its source's colour at 10%. Hovering a flow or a stage lights the path through it to 25% and quiets the rest, and the kit's tooltip names the flow from stage to stage. A stage keeps the colour it names — a chart token or any CSS colour — or takes its slot by order, grey first, and each column keeps its stages in the order given: give a plan one colour and one place in both columns of a before-and-after and it reads as itself across them. It draws no cycle: recharts places a stage by walking the flow from its sources, so the link that would close a loop is left out, as are an end that names no stage, a link from a stage to itself and a value that is not a positive number. toRechartsSankey does that sorting and is exported; the root carries data-dropped with the count and the summary says it. A stage no drawn flow touches is not drawn at all — recharts finds a stage its column by walking the flows, so it would have none — and is not counted among the stages. The labels of the outer columns sit beside the plot, in a margin estimated from their text and held to 30% of the width each side; a label wider than that is clipped at the plot's edge, so keep stage names short. One image named by its label and summary, every flow in a visually hidden list beside it (sankeyRows), and recharts' keyboard layer off, so the surface is no tab stop. In a right-to-left page the flow reads from the right.

Install

npx shadcn@latest add @vibra/sankey-chart

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

Examples

Props

PropTypeDefaultDescription
nodes{ id: string; label: string; color?: string }[]—The stages, in order; a link names a stage by its id. color is a chart token or any CSS colour, and defaults to the stage's slot by order, grey first.
links{ source: string; target: string; value: number }[]—How much moved from one stage to another. A link the chart cannot draw is left out and counted.
valueFormatter(n: number) => stringthousands-separated numberFormats every number the chart prints: the stages, the tooltip, the summary and the rows.
heightnumber280Plot height in pixels.
nodeWidthnumber10Each stage's bar, in pixels across.
nodePaddingnumber24The gap between two stages of one column, in pixels: room for the two lines of text beside each.
labelstring—Names the chart ahead of its summary, e.g. "Plan changes", and names its hidden rows.

Dependencies

Source

components/ui/sankey-chart/index.tsx
"use client"

import * as React from "react"
import { Sankey, type SankeyLinkProps, type SankeyNodeProps } from "recharts"

import { cn } from "@/lib/utils"
import { ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { chartDefaults, defaultValueFormatter, seriesColor } from "@/components/ui/chart-core"
import { ChartPlot, ChartRows } from "@/components/ui/chart-frame"
import {
  drawnStages,
  flowRows,
  type IndexedLink,
  LABEL_GAP,
  labelRoom,
  type SankeyLink,
  type SankeyNode,
  sankeySummary,
  stageValues,
  toRechartsSankey,
} from "./sankey-data"

export { sankeyRows, toRechartsSankey, type SankeyLink, type SankeyNode } from "./sankey-data"

const NODE_WIDTH = 10
// Room between two stages of one column for the two lines of text beside each.
const NODE_PADDING = 24
// The plot keeps at least this share of its width for the flows, whatever the
// labels beside the outer columns would like.
const MAX_LABEL_SHARE = 0.3

/** A flow at rest is a 10% fill; the flows on a hovered path light to 25%, and the rest step back. */
const FLOW_OPACITY = { rest: 0.1, lit: 0.25, quiet: 0.05 } as const
/** A stage off the hovered path, as a funnel step is quieted. */
const QUIET_OPACITY = 0.45

// Nothing for the config to paint: every mark takes its colour from its stage.
const CONFIG: ChartConfig = {}

// recharts' Sankey has no draw-in today, and its props do not name one; it is
// pinned off all the same (tests/motion.test.ts), so a release that adds one
// finds it off. A prop the diagram does not know is dropped before the DOM.
const NO_DRAW_IN = { isAnimationActive: false } as const

/**
 * A right-to-left page reads the flow from the right: the whole drawing —
 * marks, hit areas and the tooltip's place — is mirrored, and each text is
 * turned back about its own box so it reads the right way round.
 */
const MIRROR = "rtl:[&_.recharts-wrapper]:-scale-x-100 rtl:[&_.recharts-tooltip-wrapper>*]:-scale-x-100"
const READ_FORWARDS = "[transform-box:fill-box] origin-center rtl:-scale-x-100"

/** What the pointer is over: a stage or a flow, by its place in the lists recharts was handed. */
type Hover = { kind: "node" | "link"; index: number }
type Lit = { stages: Set<number>; flows: Set<number> } | null

/** The path through what is under the pointer: a flow and its two stages, or a stage and every flow through it. */
function litPath(hover: Hover | null, flows: IndexedLink[]): Lit {
  if (!hover) return null
  if (hover.kind === "link") {
    const flow = flows[hover.index]
    return flow ? { stages: new Set([flow.source, flow.target]), flows: new Set([hover.index]) } : null
  }
  const stages = new Set([hover.index])
  const through = new Set<number>()
  flows.forEach((flow, i) => {
    if (flow.source !== hover.index && flow.target !== hover.index) return
    through.add(i)
    stages.add(flow.source)
    stages.add(flow.target)
  })
  return { stages, flows: through }
}

// Read by every mark, so lighting a path re-renders the marks and never
// asks recharts to lay the diagram out again.
const LitPath = React.createContext<Lit>(null)

type Painted = { fill?: string }
/** A stage as the diagram holds it: its paint, and its place in the node list the flows were read against. */
type Drawn = Painted & { stage: number }

function Stage({ x, y, width, height, index, payload, formatValue }: SankeyNodeProps & { formatValue: (n: number) => string }) {
  const lit = React.useContext(LitPath)
  const quiet = lit !== null && !lit.stages.has((payload as unknown as Drawn).stage ?? index)
  // The first column writes its label before its node; every other column after.
  const first = payload.sourceNodes.length === 0 && payload.targetNodes.length > 0
  const textX = first ? x - LABEL_GAP : x + width + LABEL_GAP
  return (
    <g
      data-slot="sankey-node"
      data-side={first ? "start" : "end"}
      data-quiet={quiet || undefined}
      opacity={quiet ? QUIET_OPACITY : 1}
      className="transition-opacity duration-(--duration-base) ease-(--ease-standard)"
    >
      <rect x={x} y={y} width={width} height={Math.max(height, 1)} rx={2} fill={(payload as Painted).fill} />
      <text
        x={textX}
        y={y + height / 2}
        textAnchor={first ? "end" : "start"}
        // Anchored on the drawing's own left and right; the mirror reverses both.
        direction="ltr"
        fontSize={12}
        fill="var(--chart-axis)"
        className={READ_FORWARDS}
      >
        <tspan x={textX} dy="-0.2em">
          {payload.name}
        </tspan>
        <tspan x={textX} dy="1.25em" fontWeight={500} className="tabular-nums">
          {formatValue(Number(payload.value))}
        </tspan>
      </text>
    </g>
  )
}

function Flow({ sourceX, sourceY, sourceControlX, targetX, targetY, targetControlX, linkWidth, index, payload }: SankeyLinkProps) {
  const lit = React.useContext(LitPath)
  const strength = lit === null ? FLOW_OPACITY.rest : lit.flows.has(index) ? FLOW_OPACITY.lit : FLOW_OPACITY.quiet
  return (
    <path
      data-slot="sankey-link"
      d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
      fill="none"
      // A flow wears the colour of the stage it leaves.
      stroke={(payload.source as Painted).fill}
      strokeWidth={Math.max(linkWidth, 1)}
      strokeOpacity={strength}
      className="transition-[stroke-opacity] duration-(--duration-base) ease-(--ease-standard)"
    />
  )
}

type TooltipEntry = { payload?: { name?: string; fill?: string; source?: { name?: string; fill?: string }; target?: { name?: string } } }

/** A tooltip row: the stage, or the flow from one stage to the next, and its size through the chart's formatter. */
function tooltipRow(format: (n: number) => string) {
  return function renderRow(value: unknown, _name: unknown, item: unknown) {
    const mark = (item as { payload?: TooltipEntry }).payload?.payload
    const flow = mark?.source && mark.target
    return (
      <>
        <span className="size-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: flow ? mark.source?.fill : mark?.fill }} />
        <div className="flex flex-1 items-center justify-between gap-3 leading-none">
          <span className="text-muted-foreground">
            {flow ? (
              <>
                {mark.source?.name}{" "}
                {/* The flow's direction: it turns round with a right-to-left page, as the drawing does. */}
                <span data-slot="sankey-tooltip-arrow" className="inline-block rtl:-scale-x-100">
                  →
                </span>{" "}
                {mark.target?.name}
              </>
            ) : (
              mark?.name
            )}
          </span>
          <span className="font-medium tabular-nums">{format(Number(value))}</span>
        </div>
      </>
    )
  }
}

type DiagramProps = {
  flow: ReturnType<typeof toRechartsSankey>
  paints: string[]
  room: { start: number; end: number }
  width: number
  nodeWidth: number
  nodePadding: number
  format: (n: number) => string
  onHover: (hover: Hover | null) => void
}

/** The recharts diagram, given a frame's width so the outer labels' room can be held to a share of it. */
function SankeyDiagram({ flow, paints, room, width, nodeWidth, nodePadding, format, onHover }: DiagramProps) {
  const cap = width > 0 ? Math.round(width * MAX_LABEL_SHARE) : Number.POSITIVE_INFINITY
  const left = Math.min(room.start, cap)
  const right = Math.min(room.end, cap)
  // Both held still between renders: recharts lays the diagram out again
  // whenever its data, margin or mark renderers change.
  const margin = React.useMemo(() => ({ top: 12, right, bottom: 12, left }), [left, right])
  const data = React.useMemo(() => {
    // Only the stages a flow touches: one no flow reaches has no column to
    // stand in, and with no flow at all recharts would place every stage at
    // NaN. Each keeps its place in the node list, which the hover reads.
    const drawn = drawnStages(flow.nodes.length, flow.links)
    const at = new Map(drawn.map((stage, i) => [stage, i]))
    return {
      nodes: drawn.map((stage): Drawn & { name: string } => ({ ...flow.nodes[stage], fill: paints[stage], stage })),
      links: flow.links.map((link) => ({ ...link, source: at.get(link.source)!, target: at.get(link.target)! })),
    }
  }, [flow, paints])
  const node = React.useCallback((props: SankeyNodeProps) => <Stage {...props} formatValue={format} />, [format])
  const link = React.useCallback((props: SankeyLinkProps) => <Flow {...props} />, [])

  return (
    <Sankey
      {...NO_DRAW_IN}
      data={data}
      node={node}
      link={link}
      nodeWidth={nodeWidth}
      nodePadding={nodePadding}
      margin={margin}
      // Each column keeps the stages in the order they were given, so a plan
      // sits at the same height on both sides of a before-and-after.
      sort={false}
      // Named once, by the plot's role="img" and its summary; recharts' keyboard
      // layer would add an unnamed role="application" tab stop inside it.
      accessibilityLayer={false}
      onMouseEnter={(item, kind) =>
        onHover({ kind, index: kind === "node" ? ((item.payload as unknown as Drawn).stage ?? item.index) : item.index })
      }
      onMouseLeave={() => onHover(null)}
    >
      <ChartTooltip content={<ChartTooltipContent hideLabel formatter={tooltipRow(format)} />} />
    </Sankey>
  )
}

export type SankeyChartProps = Omit<React.ComponentProps<"div">, "children"> & {
  nodes: SankeyNode[]
  links: SankeyLink[]
  /** Formats every number the chart prints: the stages, the tooltip, the summary and the rows. */
  valueFormatter?: (n: number) => string
  /** Plot height in pixels. */
  height?: number
  /** Each stage's bar, in pixels across. */
  nodeWidth?: number
  /** The gap between two stages of one column, in pixels. */
  nodePadding?: number
  /** Names the chart ahead of its summary, e.g. "Plan changes". */
  label?: string
}

function SankeyChart({
  className,
  nodes,
  links,
  valueFormatter = defaultValueFormatter,
  height = chartDefaults.height,
  nodeWidth = NODE_WIDTH,
  nodePadding = NODE_PADDING,
  label,
  ...props
}: SankeyChartProps) {
  const flow = React.useMemo(() => toRechartsSankey(nodes, links), [nodes, links])
  const paints = React.useMemo(
    () => nodes.map((node, i) => seriesColor({ key: node.id, label: node.label, color: node.color }, i)),
    [nodes]
  )
  const [hover, setHover] = React.useState<Hover | null>(null)
  const lit = React.useMemo(() => litPath(hover, flow.links), [hover, flow.links])

  const values = stageValues(nodes.length, flow.links)
  const room = labelRoom(nodes, flow.links, (i) => valueFormatter(values[i]))
  const summary = sankeySummary(drawnStages(nodes.length, flow.links).length, flow.links, flow.dropped.length, valueFormatter)

  return (
    <div
      data-slot="sankey-chart"
      data-dropped={flow.dropped.length || undefined}
      className={cn("w-full", className)}
      {...props}
    >
      <LitPath.Provider value={lit}>
        <ChartPlot
          height={height}
          config={CONFIG}
          role="img"
          aria-label={label ? `${label}. ${summary}` : summary}
          className={cn("aspect-auto w-full", MIRROR)}
        >
          {(frame) => (
            <SankeyDiagram
              flow={flow}
              paints={paints}
              room={room}
              width={frame.width}
              nodeWidth={nodeWidth}
              nodePadding={nodePadding}
              format={valueFormatter}
              onHover={setHover}
            />
          )}
        </ChartPlot>
      </LitPath.Provider>

      <ChartRows slot="sankey-chart-data" label={`${label ?? "Sankey chart"} data`} rows={flowRows(nodes, flow.links, valueFormatter)} />
    </div>
  )
}

export { SankeyChart }
components/ui/sankey-chart/sankey-data.ts
import { defaultValueFormatter } from "@/components/ui/chart-core"

/** A stage of the flow: the id its links name, the label it is drawn with, and an optional colour. */
export type SankeyNode = {
  id: string
  label: string
  /** A chart token, or any CSS colour. Defaults to the chart slot by order, grey first. */
  color?: string
}

/** How much moved from one stage to another. */
export type SankeyLink = { source: string; target: string; value: number }

/** A flow as recharts reads it: its two ends by their place in the node list. */
export type IndexedLink = { source: number; target: number; value: number }

/**
 * The nodes and links recharts lays out — the nodes in the order given, named
 * by their labels, and the links by node index — with every link that cannot
 * be drawn set aside in `dropped` rather than drawn wrong: an end that names
 * no node, a link from a stage to itself, a value that is not a positive
 * number, and a link that would close a loop. Recharts places a stage by
 * walking the flow from its sources, and a cycle is a walk that never ends;
 * the link that closes one is the one left out, so the order of `links`
 * decides which.
 */
export function toRechartsSankey(
  nodes: SankeyNode[],
  links: SankeyLink[]
): { nodes: { name: string }[]; links: IndexedLink[]; dropped: SankeyLink[] } {
  // The first node to claim an id keeps it, so a repeated id cannot re-point
  // the flows already read against it.
  const place = new Map<string, number>()
  nodes.forEach((node, i) => {
    if (!place.has(node.id)) place.set(node.id, i)
  })

  const onward: number[][] = nodes.map(() => [])
  // Whether `from` already flows on to `to` along the links kept so far,
  // depth first.
  const reaches = (from: number, to: number): boolean => {
    const seen = new Set<number>()
    const stack = [from]
    while (stack.length > 0) {
      const at = stack.pop()!
      if (at === to) return true
      if (seen.has(at)) continue
      seen.add(at)
      stack.push(...onward[at])
    }
    return false
  }

  const kept: IndexedLink[] = []
  const dropped: SankeyLink[] = []
  for (const link of links) {
    const source = place.get(link.source)
    const target = place.get(link.target)
    const drawable =
      source !== undefined &&
      target !== undefined &&
      source !== target &&
      Number.isFinite(link.value) &&
      link.value > 0 &&
      !reaches(target, source)
    if (!drawable) {
      dropped.push(link)
      continue
    }
    onward[source].push(target)
    kept.push({ source, target, value: link.value })
  }

  return { nodes: nodes.map((node) => ({ name: node.label })), links: kept, dropped }
}

/** The data twin: one row per flow the chart draws, "Starter → Pro: 42". */
export function sankeyRows(
  nodes: SankeyNode[],
  links: SankeyLink[],
  valueFormatter: (n: number) => string = defaultValueFormatter
): { label: string; readings: string }[] {
  return flowRows(nodes, toRechartsSankey(nodes, links).links, valueFormatter)
}

/** {@link sankeyRows} for flows already sorted into the ones drawn. */
export function flowRows(
  nodes: SankeyNode[],
  kept: IndexedLink[],
  valueFormatter: (n: number) => string = defaultValueFormatter
): { label: string; readings: string }[] {
  return kept.map((link) => ({
    label: `${nodes[link.source].label} → ${nodes[link.target].label}`,
    readings: valueFormatter(link.value),
  }))
}

/**
 * Each stage's size, the way recharts sizes its node: the larger of what
 * flows in and what flows out, so a stage that passes everything on reads
 * the same on both sides.
 */
export function stageValues(stages: number, kept: IndexedLink[]): number[] {
  const into = new Array<number>(stages).fill(0)
  const out = new Array<number>(stages).fill(0)
  for (const link of kept) {
    out[link.source] += link.value
    into[link.target] += link.value
  }
  return into.map((value, i) => Math.max(value, out[i]))
}

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

/** What the chart says it shows: its flows, its stages, the sum of the flows, and any it left out. */
export function sankeySummary(
  stages: number,
  kept: IndexedLink[],
  dropped: number,
  valueFormatter: (n: number) => string = defaultValueFormatter
): string {
  const total = kept.reduce((sum, link) => sum + link.value, 0)
  const head =
    kept.length === 0
      ? "Sankey chart with no flows."
      : `Sankey chart: ${count(kept.length, "flow")} between ${count(stages, "stage")}, ${valueFormatter(total)} in all.`
  return dropped > 0 ? `${head} ${count(dropped, "flow")} left out.` : head
}

// 12px DM Sans averages about this wide a letter, and a tabular figure a
// little wider; a label's room is estimated from them because nothing can be
// measured before the plot is drawn.
const CHAR_WIDTH = 6.4
const FIGURE_WIDTH = 7.2
/** The gap between a node and the text beside it. */
export const LABEL_GAP = 8

/**
 * The stages a drawn flow touches, by their place in the node list. Only
 * these are drawn: recharts finds a stage its column by walking the flows, so
 * a stage none of them touches has no column to stand in.
 */
export function drawnStages(stages: number, kept: IndexedLink[]): number[] {
  const touched = new Set(kept.flatMap((link) => [link.source, link.target]))
  return Array.from({ length: stages }, (_, i) => i).filter((i) => touched.has(i))
}

/**
 * The room the outer columns' labels take: the first column writes its
 * labels before its nodes, the last column after them, so each needs a
 * margin as wide as its longest label or value. A stage with flows out and
 * none in is in the first column, one with flows in and none out in the
 * last; a stage between them writes its label over the flows, and a stage no
 * flow touches is not drawn, so neither needs room.
 */
export function labelRoom(
  nodes: SankeyNode[],
  kept: IndexedLink[],
  reading: (index: number) => string
): { start: number; end: number } {
  const into = new Set(kept.map((link) => link.target))
  const out = new Set(kept.map((link) => link.source))
  const width = (i: number) =>
    Math.ceil(Math.max(nodes[i].label.length * CHAR_WIDTH, reading(i).length * FIGURE_WIDTH)) + LABEL_GAP
  let start = 0
  let end = 0
  nodes.forEach((_, i) => {
    if (out.has(i) && !into.has(i)) start = Math.max(start, width(i))
    else if (into.has(i) && !out.has(i)) end = Math.max(end, width(i))
  })
  return { start, end }
}
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)}`
  })
}
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-frame.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { ChartContainer } from "@/components/ui/chart"
import { annotationRows, type ChartAnnotation } from "@/components/ui/chart-core"
import { fitsYAxis } from "@/components/ui/chart-scale"
import { ChartSkeleton } from "@/components/ui/loading-skeletons"

/* -------------------------------------------------------------------------- */
/* The frame: measure, then draw                                              */
/* -------------------------------------------------------------------------- */


// A ChartSkeleton is a legend, a plot and a row of tick labels; the two text
// rows and their gaps take about this much, so the skeleton reserves the same
// box the finished chart will fill.
const SKELETON_CHROME = 56

/** What a chart's body is told about the box it is being drawn into. */
export type ChartFrame = {
  /** The plot's own width in px, or 0 before it has been measured. */
  width: number
  /** False until one layout has happened — the skeleton holds the space until then. */
  measured: boolean
  reduced: boolean
  /** Whether the value axis both was asked for and fits in the box. */
  yAxis: (asked: boolean) => boolean
}

export type ChartPlotProps = Omit<React.ComponentProps<typeof ChartContainer>, "children"> & {
  height: number
  /** The recharts chart to draw, given what is known about the box. */
  children: (frame: ChartFrame) => React.ComponentProps<typeof ChartContainer>["children"]
}

/**
 * A chart's box: measured first, drawn second.
 *
 * Nothing can be measured on the server, so the skeleton holds the chart's
 * space until the first client layout, and the ChartContainer inside then
 * measures its own box and draws at that size — which is also what makes the
 * mount fade a fade of the finished chart rather than of a mis-sized one. The
 * width measured here is what decides whether a value axis fits, so that
 * decision is handed to the chart body rather than guessed at.
 */
function ChartPlot({ height, className, style, children, ...props }: ChartPlotProps) {
  const [width, setWidth] = React.useState(0)
  const [measured, setMeasured] = React.useState(false)
  const plot = React.useRef<HTMLDivElement | null>(null)
  // Asked of the plot itself, so a frame that asked for less motion on its own
  // wrapper is heard; the document root would not have carried it.
  const reduced = useReducedMotion(plot)

  const ref = React.useCallback((node: HTMLDivElement | null) => {
    plot.current = node
    if (!node) return
    const read = () => setWidth(Math.round(node.getBoundingClientRect().width))
    read()
    // One layout has now happened, whether or not it produced a number: an
    // environment with no layout engine reports 0 forever, and holding a
    // skeleton there would mean no chart is ever drawn at all.
    setMeasured(true)
    if (typeof ResizeObserver !== "function") return
    const observer = new ResizeObserver(read)
    observer.observe(node)
    return () => observer.disconnect()
  }, [])

  return (
    <div ref={ref} data-slot="chart-plot" className="w-full">
      {measured ? (
        <ChartContainer
          // The one piece of motion a chart has: it fades in when it first has a
          // size, and never again — a re-render on new data leaves the element
          // mounted, so the animation does not restart. Duration and easing are
          // the theme's tokens, which are zeroed under reduced motion.
          className={cn(
            !reduced && "animate-in fade-in-0 duration-(--duration-base) ease-(--ease-standard)",
            className
          )}
          style={{ height, ...style }}
          {...props}
        >
          {children({
            width,
            measured,
            reduced,
            yAxis: (asked: boolean) => asked && fitsYAxis(height, width),
          })}
        </ChartContainer>
      ) : (
        <ChartSkeleton height={Math.max(80, height - SKELETON_CHROME)} />
      )}
    </div>
  )
}

export type ChartRowsProps = {
  slot: string
  label: string
  rows: { label: string; readings: string }[]
  annotations?: ChartAnnotation[]
  valueFormatter?: (n: number) => string
  indexFormatter?: (v: string | number) => string
}

/**
 * A chart's numbers as text. Every plotted point, then every annotation drawn
 * over them — a goal line a sighted reader can see has to be readable too.
 */
function ChartRows({
  slot,
  label,
  rows,
  annotations,
  valueFormatter,
  indexFormatter,
}: ChartRowsProps) {
  const notes = annotationRows(annotations, { valueFormatter, indexFormatter })

  return (
    <ul data-slot={slot} aria-label={label} className="sr-only">
      {rows.map((row, i) => (
        <li key={`row-${i}`}>{`${row.label}: ${row.readings}`}</li>
      ))}
      {notes.map((note, i) => (
        <li key={`note-${i}`} data-slot="chart-annotation-row">
          {note}
        </li>
      ))}
    </ul>
  )
}


export { ChartPlot, ChartRows }