Business charts
The shapes product dashboards reach for: progress against a limit, shares, funnels, breakdowns per row, and actuals against a target. Same rules and motion as every chart.
ProgressRing
ring · gauge · over quota · unmeasured
A meter, not a picture. Each ring is announced with its name, value, and range. The arc stops at full; the number does not — 130% of a quota says 130%.
Colour is the caller's call. The ring never guesses thresholds; the storage example picks warn and crit itself.
Sourcecomponents/charts/progress-ring/doc.ts · components/charts/progress-ring/progress-ring.tsx · components/charts/progress-ring/progress-ring.module.css · components/charts/_kernel/ring.ts
components/charts/progress-ring/doc.ts
/**
* ProgressRing — one value against a total, as a ring or a gauge.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED, what is measured
* value number | null
* max? default 100
* kind? "ring" (full turn from 12 o'clock) | "gauge" (half turn,
* left to right over the top)
* color? a chart slot, or accent | warn | crit
* formatValue? the centre number; default the share of max, in percent
* caption? under the number
* size?, animation? (default entrance: trace)
*
* # Behaviour
*
* R1 A meter: announced with its name, value, and range.
* R2 The arc is clamped to full; the printed number never is — 130% of a
* quota says 130%.
* R3 A null value draws the empty track and a dash, and is announced as
* not measured; it is never drawn as zero.
* R4 Colour is the caller's decision: the ring does not guess thresholds.
*/
export {};components/charts/progress-ring/progress-ring.tsx
"use client";
import type { CSSProperties } from "react";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { colorVar, type ChartColor } from "../_kernel/encode";
import { ringGeometry, ringShare, type RingKind } from "../_kernel/ring";
import { isValue } from "../_kernel/scale";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./progress-ring.module.css";
export type ProgressRingProps = {
/** What is measured: the meter's name. */
label: string;
/** Null is not measured, drawn as an empty track and a dash. */
value: number | null;
max?: number;
/** A ring, or a half-ring gauge. */
kind?: RingKind;
/** A chart slot, or a status colour for a threshold the caller decided. */
color?: ChartColor | "accent" | "warn" | "crit";
/** The number in the middle; default the share of max as a percentage. */
formatValue?: (value: number, max: number) => string;
/** Under the number: "of seats", "used". */
caption?: string;
/** The ring's width as a CSS length. */
size?: string;
/** Default: the arc traces round, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
const percentOf = (value: number, max: number) =>
`${Math.round((value / max) * 100)}%`;
const tone = (color: ProgressRingProps["color"]) =>
color === "accent" || color === "warn" || color === "crit"
? `var(--${color})`
: colorVar(color ?? 1);
/** One value against a total. It is a meter: announced with its value, and
* its printed number is never clamped even when the arc is full. */
export function ProgressRing({
label,
value,
max = 100,
kind = "ring",
color = 1,
formatValue = percentOf,
caption,
size,
animation,
className,
}: ProgressRingProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "trace",
axis: "x",
});
const measured = isValue(value);
const shown = useTweened({ share: ringShare(value, max) }, update);
const ring = ringGeometry(shown.share, kind);
const text = measured ? formatValue(value, max) : "—";
return (
<div
{...rootProps}
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={measured ? value : undefined}
aria-valuetext={
measured ? `${text}${caption ? ` ${caption}` : ""}` : "Not measured"
}
data-kind={kind}
className={cn(chart.root, styles.root, className)}
style={
{
"--series": tone(color),
"--ring-size": size,
} as CSSProperties
}
>
<svg
className={styles.svg}
viewBox={ring.viewBox}
data-rotate={ring.rotate || undefined}
aria-hidden="true"
>
<path
className={styles.track}
d={ring.track}
strokeWidth={ring.width}
/>
{ring.value ? (
<path
data-mark
className={styles.value}
d={ring.value}
pathLength={1}
strokeWidth={ring.width}
/>
) : null}
</svg>
<div className={styles.centre} aria-hidden="true">
<span className={styles.number}>{text}</span>
{caption ? <span className={styles.caption}>{caption}</span> : null}
</div>
</div>
);
}components/charts/progress-ring/progress-ring.module.css
@layer primitive {
.root {
position: relative;
display: inline-grid;
width: var(--ring-size, 7rem);
justify-items: center;
}
.svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.svg[data-rotate] {
transform: rotate(-90deg);
}
.track {
fill: none;
stroke: var(--chart-absent, var(--surface-hover));
stroke-linecap: round;
}
.value {
fill: none;
stroke: var(--series);
stroke-dasharray: 1 1;
stroke-linecap: round;
}
.centre {
position: absolute;
inset: 0;
display: grid;
align-content: center;
justify-items: center;
gap: 2px;
text-align: center;
}
.root[data-kind="gauge"] .centre {
top: auto;
bottom: 0;
}
.number {
color: var(--ink);
font-size: calc(var(--ring-size, 7rem) * 0.2);
font-variant-numeric: tabular-nums;
font-weight: var(--weight-strong);
line-height: 1;
}
.caption {
max-width: 80%;
color: var(--ink-3);
font-size: var(--text-11);
line-height: 1.2;
}
}components/charts/_kernel/ring.ts
import { arcPath } from "./donut";
import { isValue } from "./scale";
/* ProgressRing: one value against a total, as a ring or a half-ring gauge. */
export type RingKind = "ring" | "gauge";
/** The share of the total, clamped to 0–1 for drawing; the printed value is
* never clamped. */
export function ringShare(value: number | null, max: number) {
if (!isValue(value) || !(max > 0)) return 0;
return Math.max(0, Math.min(1, value / max));
}
/** Geometry for a ring (a full turn from 12 o'clock) or a gauge (a half
* turn over the top, 9 to 3 o'clock), in a 100-wide viewBox. */
export function ringGeometry(share: number, kind: RingKind, thickness = 0.16) {
const width = 100 * thickness * 0.5;
if (kind === "gauge") {
// Unrotated: 0.5 turns is 9 o'clock, going clockwise over the top.
const r = 50 - width / 2 - 1;
const cy = 52;
return {
viewBox: `0 0 100 ${cy + width / 2 + 1}`,
width,
track: arcPath(0.5, 0.99999, r, 50, cy),
value: share > 0 ? arcPath(0.5, 0.5 + share / 2, r, 50, cy) : "",
rotate: false,
};
}
const r = 50 - width / 2 - 1;
return {
viewBox: "0 0 100 100",
width,
track: arcPath(0, 0.99999, r),
value: share > 0 ? arcPath(0, share, r) : "",
// A full ring starts at 12: the SVG is turned a quarter.
rotate: true,
};
}PieChart
a donut with thickness 1
A pie is a donut filled to the centre.
- Free1,840 · 63.9%
- Starter612 · 21.2%
- Team388 · 13.5%
- Enterprise41 · 1.4%
For two to four parts. Angles are harder to compare than lengths; past four, prefer a bar chart.
Sourcecomponents/charts/donut-chart/doc.ts · components/charts/donut-chart/donut-chart.tsx
components/charts/donut-chart/doc.ts
/**
* DonutChart — shares of one whole.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED, names the chart
* data { key, label, value: number | null }[]
* totalLabel? under the total in the centre, default "Total"
* formatValue? default: every digit
* thickness? the ring's width as a share of its radius; 1 is a pie
* PieChart DonutChart with thickness 1
* animation? default: segments trace round in turn, when scrolled
* into view
*
* # Behaviour
*
* R1 A group named by the label; the legend lists every part with its exact
* value and share. The ring itself is decorative.
* R2 At most four hues: with more than five parts, the fifth onward fold
* into "Other (n)", drawn neutral. Five parts draw the fifth neutral.
* R3 Zero is a measured part with no segment; null, negative, or invalid is
* "Unavailable" and excluded from the total.
* R4 A total of zero draws the empty track and a dash in the centre.
* R5 Parts keep the caller's order, clockwise from twelve o'clock.
* R6 A pie (thickness over 0.6) has no centre total; the legend's name
* carries it.
*/
export {};components/charts/donut-chart/donut-chart.tsx
"use client";
import type { CSSProperties } from "react";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { formatExact, formatPercent } from "../_kernel/format";
import {
donutRing,
donutArcs,
donutParts,
donutTarget,
measuredPart,
type DonutDatum,
} from "../_kernel/donut";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./donut-chart.module.css";
export type DonutChartProps = {
/** Names the chart. */
label: string;
/** Parts of one whole. Past the fourth, parts fold into "Other". */
data: readonly DonutDatum[];
/** Under the total in the middle. */
totalLabel?: string;
formatValue?: (value: number) => string;
/** The ring's width as a share of its radius: 1 is a pie. */
thickness?: number;
/** Default: each segment traces round in turn, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
/** Shares of one whole, with the exact values and percentages beside it. For
* a handful of parts; past four, the rest fold into "Other", because a fifth
* hue would repeat one and small slices are unreadable anyway. */
export function DonutChart({
label,
data,
totalLabel = "Total",
formatValue = formatExact,
thickness = 0.28,
animation,
className,
}: DonutChartProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "trace",
axis: "x",
});
const parts = donutParts(data);
const target = donutTarget(parts);
const total = Object.values(target).reduce((a, b) => a + b, 0);
const shown = useTweened(target, update);
const arcs = donutArcs(shown, parts, thickness);
const ring = donutRing(thickness);
// A pie has no hole to print the total in; the legend's name carries it.
const pie = thickness > 0.6;
return (
<div {...rootProps} className={cn(chart.root, styles.wrap, className)}>
{data.length === 0 ? (
<p className={chart.empty}>No data to display.</p>
) : (
<div className={styles.layout} role="group" aria-label={label}>
<div className={styles.ring}>
<svg
className={styles.svg}
viewBox="0 0 100 100"
aria-hidden="true"
>
<circle
className={styles.track}
cx="50"
cy="50"
r={ring.radius}
strokeWidth={ring.width}
/>
{arcs.map((part) => (
<path
key={part.key}
data-mark
className={styles.arc}
d={part.d}
pathLength={1}
strokeWidth={ring.width}
style={{ "--series": part.color } as CSSProperties}
/>
))}
</svg>
{pie ? null : (
<div className={styles.centre} aria-hidden="true">
<span className={styles.total}>
{total > 0 ? formatValue(total) : "—"}
</span>
<span className={styles.caption}>{totalLabel}</span>
</div>
)}
</div>
<ChartLegend
className={styles.legend}
layout="list"
label={`${label}: ${totalLabel.toLowerCase()} ${formatValue(total)}`}
items={parts.map((part) => ({
key: part.key,
label: part.label,
color: part.color,
value: measuredPart(part.value)
? formatValue(part.value)
: "Unavailable",
note:
measuredPart(part.value) && total > 0
? formatPercent(part.value / total)
: undefined,
}))}
/>
</div>
)}
</div>
);
}
export type { DonutDatum };
/** A donut filled to the centre. For two to four parts; past that, prefer
* a bar chart — angles are harder to compare than lengths. */
export function PieChart(props: Omit<DonutChartProps, "thickness">) {
return <DonutChart {...props} thickness={1} />;
}FunnelChart
conversion from previous and first
Each stage with its conversion from the stage before and from the first.
- Visited pricing12,400
- Signed up3,10025% of previous · 25% of first
- Activated1,48047.7% of previous · 11.9% of first
- Subscribed41027.7% of previous · 3.3% of first
- Retained 90 days29070.7% of previous · 2.3% of first
Sourcecomponents/charts/funnel-chart/doc.ts · components/charts/funnel-chart/funnel-chart.tsx · components/charts/funnel-chart/funnel-chart.module.css · components/charts/_kernel/funnel.ts
components/charts/funnel-chart/doc.ts
/**
* FunnelChart — stages that each keep part of the one before.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED
* steps { key, label, value: number | null }[], in order
* color?, formatValue?, animation? (default entrance: grow from centre)
*
* # Behaviour
*
* R1 An ordered list; every stage prints its value, and every stage after
* the first prints its conversion from the stage before and from the
* first.
* R2 Bars are centred and measured against the largest stage.
* R3 An unmeasured stage reads "Unavailable", draws no bar, and breaks the
* conversions that would need it: a gap never invents a rate.
* R4 Stages are not reordered: the order is the process.
*/
export {};components/charts/funnel-chart/funnel-chart.tsx
"use client";
import type { CSSProperties } from "react";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { colorVar, type ChartColor } from "../_kernel/encode";
import { formatExact, formatPercent } from "../_kernel/format";
import { funnelMax, funnelRows, type FunnelStep } from "../_kernel/funnel";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./funnel-chart.module.css";
export type { FunnelStep };
export type FunnelChartProps = {
/** Names the chart. */
label: string;
/** In order: each stage is part of the one before. */
steps: readonly FunnelStep[];
color?: ChartColor;
formatValue?: (value: number) => string;
/** Default: stages grow out from the centre, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
/** Stages that each keep part of the one before: signups, activated, paid.
* Every stage prints its value and its conversion from the stage before and
* from the first; an unmeasured stage breaks the conversions next to it
* rather than inventing them. */
export function FunnelChart({
label,
steps,
color = 1,
formatValue = formatExact,
animation,
className,
}: FunnelChartProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "grow",
axis: "x",
});
const rows = funnelRows(steps);
const target: Record<string, number> = { __max: funnelMax(steps) };
for (const row of rows)
if (row.measured) target[row.key] = row.value as number;
const shown = useTweened(target, update);
return (
<div
{...rootProps}
className={cn(chart.root, className)}
style={{ "--series": colorVar(color) } as CSSProperties}
>
{steps.length === 0 ? (
<p className={chart.empty}>No data to display.</p>
) : (
<ol aria-label={label} className={styles.list}>
{rows.map((row, index) => {
const width =
row.measured && shown.__max > 0
? Math.min(1, (shown[row.key] ?? 0) / shown.__max)
: 0;
return (
<li key={row.key} className={styles.row}>
<span className={styles.label}>{row.label}</span>
<span className={styles.track} aria-hidden="true">
{row.measured ? (
<span
data-mark
className={styles.fill}
style={{ width: `${width * 100}%` }}
/>
) : null}
</span>
<span className={styles.figures}>
<span className={styles.value}>
{row.measured
? formatValue(row.value as number)
: "Unavailable"}
</span>
{index > 0 ? (
<span className={styles.rate}>
{row.ofPrevious !== null
? `${formatPercent(row.ofPrevious)} of previous`
: "—"}
{row.ofFirst !== null
? ` · ${formatPercent(row.ofFirst)} of first`
: ""}
</span>
) : null}
</span>
</li>
);
})}
</ol>
)}
</div>
);
}components/charts/funnel-chart/funnel-chart.module.css
@layer primitive {
.list {
display: grid;
grid-template-columns: minmax(6rem, 1fr) minmax(8rem, 3fr) auto;
gap: var(--space-3) var(--space-5);
margin: 0;
padding: 0;
list-style: none;
font-size: var(--text-12);
}
.row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.label {
color: var(--ink-2);
}
/* Centred, so each stage reads as what is left of the one above. */
.track {
display: flex;
height: 2rem;
justify-content: center;
border-radius: var(--radius-1);
background: var(--chart-absent, var(--surface-hover));
}
.fill {
height: 100%;
border-radius: inherit;
background: var(--series);
transform-origin: 50% 50%;
}
.figures {
display: grid;
justify-items: end;
font-variant-numeric: tabular-nums;
}
.value {
color: var(--ink);
}
.rate {
color: var(--ink-3);
font-size: var(--text-11);
}
}components/charts/_kernel/funnel.ts
import { isValue } from "./scale";
/* FunnelChart: stages that each keep part of the one before. */
export type FunnelStep = { key: string; label: string; value: number | null };
export type FunnelRow = FunnelStep & {
measured: boolean;
/** Share of the first stage, 0–1; null when either is unmeasured. */
ofFirst: number | null;
/** Share of the stage before, 0–1; null for the first stage or when
* either is unmeasured — a gap never invents a conversion. */
ofPrevious: number | null;
};
const measured = (v: number | null): v is number => isValue(v) && v >= 0;
export function funnelRows(steps: readonly FunnelStep[]): FunnelRow[] {
const first = steps[0]?.value ?? null;
return steps.map((step, index) => {
const previous = index > 0 ? steps[index - 1].value : null;
const ok = measured(step.value);
return {
...step,
measured: ok,
ofFirst:
ok && measured(first) && first > 0
? (step.value as number) / first
: null,
ofPrevious:
index > 0 && ok && measured(previous) && previous > 0
? (step.value as number) / previous
: null,
};
});
}
/** The value a full-width bar stands for: the largest stage. */
export function funnelMax(steps: readonly FunnelStep[]) {
return Math.max(0, ...steps.map((s) => s.value).filter(measured));
}StackedBarChart
stacked · percent · diverging
Stacked: totals compare across rows.
- New work
- Fixes
- Review
View data for Hours by team and kind of work
| Label | New work | Fixes | Review |
|---|---|---|---|
| Platform | 124 h | 46 h | 30 h |
| Growth | 88 h | 22 h | 18 h |
| Support | 12 h | 96 h | 8 h |
| Design | 64 h | 6 h | 26 h |
Percent: each team's own split.
- New work
- Fixes
- Review
View data for Share of hours by kind of work, per team
| Label | New work | Fixes | Review |
|---|---|---|---|
| Platform | 124 h | 46 h | 30 h |
| Growth | 88 h | 22 h | 18 h |
| Support | 12 h | 96 h | 8 h |
| Design | 64 h | 6 h | 26 h |
Diverging: disagreement left of centre, agreement right.
- Strongly disagree
- Disagree
- Agree
- Strongly agree
View data for Survey answers per question, % of responses
| Label | Strongly disagree | Disagree | Agree | Strongly agree |
|---|---|---|---|---|
| Easy to set up | 4% | 9% | 48% | 39% |
| Fast enough | 8% | 21% | 44% | 27% |
| Good value | 12% | 26% | 40% | 22% |
| Would recommend | 5% | 11% | 45% | 39% |
Percent hides the base, so it prints it. Every row fills the track, and its total is still shown beside it.
Diverging shares one scale. Both sides are measured against the larger side of the largest row, so left and right compare directly.
Sourcecomponents/charts/stacked-bar-chart/doc.ts · components/charts/stacked-bar-chart/stacked-bar-chart.tsx · components/charts/stacked-bar-chart/stacked-bar-chart.module.css · components/charts/_kernel/hbars.ts
components/charts/stacked-bar-chart/doc.ts
/**
* StackedBarChart — horizontal bars of several parts per row.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED
* data { label, values: { [series key]: number | null } }[]
* series { key, label, color?, side?: "negative" | "positive" }[]
* layout? "stacked" (default) | "percent" | "diverging"
* formatValue?, animation? (default entrance: grow)
*
* # Behaviour
*
* R1 stacked: parts laid end to end; every row shares one scale, so row
* totals compare. The total is printed.
* R2 percent: every row fills the track; parts are shares of their row.
* The row's total is still printed, so the base is never hidden.
* R3 diverging: negative-side series run left from a centre line and
* positive-side series run right, on one scale for both sides. List the
* negative series first, the one nearest neutral last.
* R4 Only finite, non-negative values are drawn; the table lists every
* value, "Unavailable" for the rest.
* R5 A legend names the series; the data table holds every exact value.
*/
export {};components/charts/stacked-bar-chart/stacked-bar-chart.tsx
"use client";
import type { CSSProperties } from "react";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import type { CartesianDatum } from "../_kernel/cartesian";
import { formatExact } from "../_kernel/format";
import {
stackedBarRows,
stackedBarTarget,
type SidedSeries,
type StackedBarLayout,
} from "../_kernel/hbars";
import chart from "../_shared/chart.module.css";
import { SeriesLegend } from "../_shared/series-legend";
import { SeriesTable } from "../_shared/series-table";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./stacked-bar-chart.module.css";
export type { SidedSeries, StackedBarLayout };
export type StackedBarChartProps = {
/** Names the chart. */
label: string;
data: readonly CartesianDatum[];
/** For "diverging", give each series a side; negative ones are listed
* first, the one nearest neutral last. */
series: readonly SidedSeries[];
/** Parts of each row's total; each row as 100%; or two sides of a centre
* (disagree against agree). */
layout?: StackedBarLayout;
formatValue?: (value: number) => string;
/** Default: segments grow out, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
/** Horizontal bars of several parts per row: a breakdown per team, a
* survey's answers. Values are in the table; totals are printed. */
export function StackedBarChart({
label,
data,
series,
layout = "stacked",
formatValue = formatExact,
animation,
className,
}: StackedBarChartProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "grow",
axis: "x",
});
const shown = useTweened(stackedBarTarget(data, series, layout), update);
const rows = stackedBarRows(shown, data, series, layout);
if (!data.length || !series.length)
return (
<div className={cn(chart.root, className)}>
<p className={chart.empty}>No data to display.</p>
</div>
);
return (
<div {...rootProps} className={cn(chart.root, className)}>
<SeriesLegend series={series} lines={false} />
<ul aria-label={label} className={styles.list}>
{rows.map((row, index) => (
<li key={`${index}-${row.label}`} className={styles.row}>
<span className={styles.label}>{row.label}</span>
<span className={styles.track} aria-hidden="true">
{row.segments.map((segment) => (
<span
key={segment.key}
data-mark
data-side={segment.side}
className={styles.segment}
style={
{
"--series": segment.color,
left: `${segment.left}%`,
width: `${segment.width}%`,
} as CSSProperties
}
/>
))}
{layout === "diverging" ? (
<span className={styles.centre} />
) : null}
</span>
<span className={styles.total}>
{layout === "diverging" ? "" : formatValue(row.total)}
</span>
</li>
))}
</ul>
<SeriesTable
label={label}
data={data}
series={series}
formatValue={formatValue}
/>
</div>
);
}components/charts/stacked-bar-chart/stacked-bar-chart.module.css
@layer primitive {
.list {
display: grid;
grid-template-columns: minmax(5rem, 1fr) minmax(8rem, 4fr) auto;
gap: var(--space-4) var(--space-5);
margin: 0;
padding: 0;
list-style: none;
font-size: var(--text-12);
}
.row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.label {
min-width: 0;
color: var(--ink-2);
overflow-wrap: anywhere;
}
.track {
position: relative;
height: 0.875rem;
overflow: hidden;
border-radius: var(--radius-1);
background: var(--chart-absent, var(--surface-hover));
}
.segment {
position: absolute;
top: 0;
bottom: 0;
background: var(--series);
transform-origin: 0 50%;
}
/* A hairline of the surface between segments, so neighbours read apart. */
.segment + .segment {
box-shadow: inset 1px 0 0 var(--surface-panel);
}
.segment[data-side="negative"] {
transform-origin: 100% 50%;
}
.centre {
position: absolute;
top: -2px;
bottom: -2px;
left: 50%;
width: 1px;
background: var(--chart-axis, var(--line-strong));
}
.total {
min-width: 3ch;
color: var(--ink);
font-variant-numeric: tabular-nums;
text-align: end;
}
}components/charts/_kernel/hbars.ts
import type { CartesianDatum } from "./cartesian";
import { seriesColor, type ChartSeries } from "./encode";
import { isValue } from "./scale";
/* StackedBarChart: horizontal bars of several series per row. */
export type StackedBarLayout = "stacked" | "percent" | "diverging";
/** A series, and (diverging only) which side of the centre it sits on. */
export type SidedSeries = ChartSeries & { side?: "negative" | "positive" };
const cell = (key: string, index: number) => `${key}|${index}`;
const measured = (v: number | null | undefined): v is number =>
isValue(v) && v >= 0;
/** The numbers to tween: every drawable value, and the scale's end (__max):
* the largest row total; 100 for percent; for diverging, the larger side of
* the largest row, so both halves share one scale. */
export function stackedBarTarget(
data: readonly CartesianDatum[],
series: readonly SidedSeries[],
layout: StackedBarLayout,
) {
const target: Record<string, number> = {};
let max = 0;
data.forEach((row, index) => {
let total = 0;
let negative = 0;
let positive = 0;
for (const entry of series) {
const v = row.values[entry.key];
if (!measured(v)) continue;
total += v;
if (entry.side === "negative") negative += v;
else positive += v;
}
for (const entry of series) {
const v = row.values[entry.key];
if (!measured(v)) continue;
target[cell(entry.key, index)] =
layout === "percent" ? (total > 0 ? (v / total) * 100 : 0) : v;
}
max = Math.max(
max,
layout === "diverging" ? Math.max(negative, positive) : total,
);
});
target.__max = layout === "percent" ? 100 : max;
return target;
}
export type Segment = {
key: string;
color: string;
/** Left edge and width, in percent of the track. */
left: number;
width: number;
side: "negative" | "positive";
};
/** Each row's segments. Stacked and percent run left to right; diverging
* runs outward from the centre, the last negative series nearest it. */
export function stackedBarRows(
shown: Readonly<Record<string, number>>,
data: readonly CartesianDatum[],
series: readonly SidedSeries[],
layout: StackedBarLayout,
) {
const max = shown.__max || 1;
const colors = new Map(
series.map((entry, i) => [entry.key, seriesColor(entry, i)]),
);
return data.map((row, index) => {
const segments: Segment[] = [];
const at = (key: string) => shown[cell(key, index)];
if (layout === "diverging") {
const scale = 50 / max;
let left = 50;
for (const entry of series
.filter((e) => e.side === "negative")
.toReversed()) {
const v = at(entry.key);
if (v === undefined) continue;
left -= v * scale;
segments.push({
key: entry.key,
color: colors.get(entry.key)!,
left,
width: v * scale,
side: "negative",
});
}
let right = 50;
for (const entry of series.filter((e) => e.side !== "negative")) {
const v = at(entry.key);
if (v === undefined) continue;
segments.push({
key: entry.key,
color: colors.get(entry.key)!,
left: right,
width: v * scale,
side: "positive",
});
right += v * scale;
}
} else {
let left = 0;
for (const entry of series) {
const v = at(entry.key);
if (v === undefined) continue;
const width = (v / max) * 100;
segments.push({
key: entry.key,
color: colors.get(entry.key)!,
left,
width,
side: "positive",
});
left += width;
}
}
let total = 0;
for (const entry of series) {
const v = row.values[entry.key];
if (measured(v)) total += v;
}
return { label: row.label, segments, total };
});
}ComboChart and ParetoChart
one axis, always
Columns and a line on one axis.
- Revenue
- Target
View data for Monthly revenue against target, thousands
| Label | Revenue | Target |
|---|---|---|
| Apr | $63.6k | $60k |
| May | $65.2k | $65k |
| Jun | $74k | $70k |
| Jul | $75.5k | $75k |
| Aug | $79.6k | $80k |
| Sep | $87k | $85k |
Pareto: shares and the running total, both in percent.
- Share of total
- Running total
View data for Support tickets by cause, this quarter
| Item | Tickets | Share | Running total |
|---|---|---|---|
| Login and SSO | 412 | 38.5% | 38.5% |
| Billing questions | 268 | 25% | 63.5% |
| Failed deploys | 190 | 17.7% | 81.2% |
| Usage limits | 96 | 9% | 90.2% |
| API errors | 61 | 5.7% | 95.9% |
| Other | 44 | 4.1% | 100% |
No second axis. Columns and lines share one. Measures in different units belong in two charts — which is why the Pareto draws both its columns and its line in percent, with the counts in the table.
Sourcecomponents/charts/combo-chart/doc.ts · components/charts/combo-chart/combo-chart.tsx · components/charts/_kernel/combo.ts
components/charts/combo-chart/doc.ts
/**
* ComboChart / ParetoChart — columns and lines on one axis.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* ComboChart label, data, bars (series), lines (series), layout?
* ("grouped" | "stacked"), formatValue?, formatTick?,
* height?, table?, animation?, inspect?
* ParetoChart label, data: { id, label, value }[], valueTitle?,
* formatValue?, height?, animation?, inspect?
*
* # Behaviour
*
* R1 One y axis, shared by columns and lines. Measures in different units
* are not combined; they belong in two charts.
* R2 Line points sit at the centre of their columns.
* R3 Series keys are distinct across bars and lines.
* R4 Pareto ranks items largest first, draws each as its share of the
* total, and the running total as a line — both in percent, so one axis
* is honest. The table has the counts, shares, and running totals.
* R5 Inspection (inspect, default on): the pointer shows the nearest
* position — a crosshair or band, a marker on each line, and a card with
* every series' value (missing ones say "Not measured"; stacks add the
* total, percent stacks each share). The plot is one tab stop: ← → step,
* Home and End jump, Escape lets go, and each step is announced as one
* sentence. The card is for the eye; the table stays the full record.
*/
export {};components/charts/combo-chart/combo-chart.tsx
"use client";
import type { CSSProperties, ReactNode } from "react";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import type { CartesianDatum } from "../_kernel/cartesian";
import { columnGeometry } from "../_kernel/column";
import { comboTarget } from "../_kernel/combo";
import { seriesColor, type ChartSeries } from "../_kernel/encode";
import {
formatExact,
formatPercent,
formatPercentTick,
formatTick,
} from "../_kernel/format";
import { lineMarkers, readout } from "../_kernel/inspect";
import { linesGeometry } from "../_kernel/lines";
import { paretoRows, rank, type RankedDatum } from "../_kernel/ranked";
import { band, px } from "../_kernel/scale";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { SeriesTable } from "../_shared/series-table";
import { useChartMotion } from "../_shared/use-chart-motion";
export type ComboChartProps = {
/** Names the chart. */
label: string;
data: readonly CartesianDatum[];
/** Drawn as columns. */
bars: readonly ChartSeries[];
/** Drawn as lines over the columns, on the SAME axis: there is no second
* axis. Measures in different units belong in two charts. */
lines: readonly ChartSeries[];
layout?: "grouped" | "stacked";
formatValue?: (value: number) => string;
formatTick?: (value: number) => string;
height?: string;
/** Replace the data table (a chart that knows more than its series). */
table?: ReactNode;
/** Show every x label, angled: for categories that must all be read. */
angled?: boolean;
/** Default: columns grow, when scrolled into view. */
animation?: AnimationProp;
/** Read values position by position, by pointer or keyboard (default). */
inspect?: boolean;
className?: string;
};
/** Columns with lines over them, on one axis: actual against target,
* volume against its moving average. */
export function ComboChart({
label,
data,
bars,
lines,
layout = "grouped",
formatValue = formatExact,
formatTick: tickFormat = formatTick,
height,
table,
angled = false,
animation,
inspect = true,
className,
}: ComboChartProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "grow",
axis: "y",
});
const { target, step, measured } = comboTarget(data, bars, lines, layout);
const shown = useTweened(target, update);
if (!data.length || (!bars.length && !lines.length))
return (
<div className={cn(chart.root, className)}>
<p className={chart.empty}>No data to display.</p>
</div>
);
const columns = columnGeometry(
shown,
data,
bars,
layout === "stacked",
step,
tickFormat,
);
const slots = band(data.length, 0.3);
const trend = linesGeometry(shown, data, lines, {
area: false,
stacked: false,
points: true,
step,
positions: (index) => slots.centre(index),
});
const all = [...bars, ...lines];
const colors = all.map((entry, index) => seriesColor(entry, index));
const trendSeries = trend.series.map((entry, index) => ({
...entry,
color: colors[bars.length + index],
}));
return (
<div {...rootProps} className={cn(chart.root, className)}>
<ChartLegend
items={all.map((entry) => {
const inBars = bars.includes(entry);
const index = inBars
? bars.indexOf(entry)
: bars.length + lines.indexOf(entry);
return {
key: entry.key,
label: entry.label,
color: seriesColor(entry, index),
line: inBars ? undefined : (entry.line ?? "solid"),
};
})}
/>
{measured ? (
<CartesianPlot
label={label}
ticks={columns.ticks}
height={height}
zeroAt={columns.zeroAt}
xLabels={
angled
? data.map((row, index) => ({
key: `${index}-${row.label}`,
at: px(slots.centre(index)),
label: row.label,
align: "middle" as const,
tier: 2 as const,
}))
: columns.xLabels
}
angled={angled}
overlay={
inspect ? (
<ChartInspector
label={label}
positions={columns.positions}
band={columns.bandWidth}
readout={(index) =>
readout(data[index], all, { format: formatValue, colors })
}
markers={(index) => lineMarkers(trendSeries, index)}
/>
) : null
}
>
{columns.series.map((entry) => (
<g
key={entry.key}
style={{ "--series": entry.color } as CSSProperties}
>
{entry.rects.map((rect) => (
<rect
key={rect.key}
data-mark
data-negative={rect.negative || undefined}
className={chart.column}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
/>
))}
</g>
))}
{trend.series.map((entry, index) => (
<g
key={entry.key}
style={
{
"--series": seriesColor(lines[index], bars.length + index),
} as CSSProperties
}
>
<path
className={chart.line}
data-line={entry.line}
d={entry.path}
/>
{entry.points.map((point) => (
<path key={point.key} className={chart.point} d={point.d} />
))}
</g>
))}
</CartesianPlot>
) : (
<p className={chart.empty}>Measurements unavailable.</p>
)}
{table ?? (
<SeriesTable
label={label}
data={data}
series={all}
formatValue={formatValue}
/>
)}
</div>
);
}
export type ParetoChartProps = {
/** Names the chart. */
label: string;
data: readonly RankedDatum[];
/** What the values count, for the table. */
valueTitle?: string;
formatValue?: (value: number) => string;
height?: string;
animation?: AnimationProp;
inspect?: boolean;
className?: string;
};
/** Causes ranked largest first, each column its share of the total and the
* line the running total — both in percent, so one axis is honest. The
* counts are in the table. */
export function ParetoChart({
label,
data,
valueTitle = "Count",
formatValue = formatExact,
height,
animation,
inspect,
className,
}: ParetoChartProps) {
const { measured, unmeasured } = rank(data);
const rows = paretoRows(measured);
return (
<ComboChart
label={label}
className={className}
height={height}
animation={animation}
inspect={inspect}
formatValue={(value) => formatPercent(value / 100)}
data={rows.map((row) => ({
label: row.item.label,
values: { share: row.share, cumulative: row.cumulative },
}))}
bars={[{ key: "share", label: "Share of total" }]}
lines={[{ key: "cumulative", label: "Running total" }]}
formatTick={formatPercentTick}
angled
table={
<ChartData label={label}>
<THead>
<Tr>
<Th>Item</Th>
<Th numeric>{valueTitle}</Th>
<Th numeric>Share</Th>
<Th numeric>Running total</Th>
</Tr>
</THead>
<TBody>
{rows.map((row) => (
<Tr key={row.item.id}>
<Th scope="row">{row.item.label}</Th>
<Td numeric>{formatValue(row.item.value)}</Td>
<Td numeric>{formatPercent(row.share / 100)}</Td>
<Td numeric>{formatPercent(row.cumulative / 100)}</Td>
</Tr>
))}
{unmeasured.map((item) => (
<Tr key={item.id}>
<Th scope="row">{item.label}</Th>
<Td numeric>Unavailable</Td>
<Td numeric>—</Td>
<Td numeric>—</Td>
</Tr>
))}
</TBody>
</ChartData>
}
/>
);
}components/charts/_kernel/combo.ts
import type { CartesianDatum } from "./cartesian";
import { columnTarget, type ColumnLayout } from "./column";
import type { ChartSeries } from "./encode";
import { linesTarget } from "./lines";
import { niceDomain } from "./scale";
/* ComboChart: columns and lines on ONE axis. Series keys must be distinct
across the two, since both tween in one record. */
export function comboTarget(
data: readonly CartesianDatum[],
bars: readonly ChartSeries[],
lines: readonly ChartSeries[],
layout: Exclude<ColumnLayout, "percent">,
) {
const columns = columnTarget(data, bars, layout);
const trend = linesTarget(data, lines, { mode: "none", zero: true });
const { domain, step } = niceDomain([
columns.target.__lo,
columns.target.__hi,
trend.target.__lo,
trend.target.__hi,
]);
return {
target: {
...columns.target,
...trend.target,
__lo: domain[0],
__hi: domain[1],
},
step,
measured: columns.measured || trend.measured,
};
}RadarChart
one scale · a broken outline
Six qualities, one 0–10 scale.
- Bento
- Alternative
View data for Product qualities, Bento against an alternative, 0 to 10
| Attribute | Bento | Alternative |
|---|---|---|
| Speed | 8.4 | 6.1 |
| Reliability | 9.1 | 7.4 |
| Support | 7.2 | 8.3 |
| Integrations | 6.5 | 8.8 |
| Value | 8 | 5.9 |
| Ease of use | 8.8 | 6.6 |
The outline breaks; nothing is guessed.
View data for Product qualities with support unmeasured
| Attribute | Bento |
|---|---|
| Speed | 8.4 |
| Reliability | 9.1 |
| Support | Unavailable |
| Integrations | 6.5 |
| Value | 8 |
| Ease of use | 8.8 |
One scale for every spoke. A radar only compares when every axis runs from the same zero to the same maximum.
Read the shape, not the area. The area depends on the order of the axes, so two identical profiles can look different sizes if their axes are ordered differently.
Sourcecomponents/charts/radar-chart/doc.ts · components/charts/radar-chart/radar-chart.tsx · components/charts/radar-chart/radar-chart.module.css · components/charts/_kernel/radar.ts
components/charts/radar-chart/doc.ts
/**
* RadarChart — profiles across a handful of attributes.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED
* axes { key, label, values: { [series key]: number | null } }[]
* — one per spoke, 3–8
* series one to three { key, label, color?, line? }
* max? the value at the rim; default a round number above the
* largest value
* formatValue?, formatTick?, animation? (default: grow from the centre)
*
* # Behaviour
*
* R1 One scale for every axis, from zero at the centre to max at the rim.
* A radar only compares when the scale is common.
* R2 An unmeasured axis breaks a series' outline: the measured neighbours
* are joined, the shape is not filled, and no vertex is guessed.
* R3 Fewer than three axes draws nothing and says so.
* R4 Values above max sit on the rim; the table has them exactly.
* R5 Read the shape, not the area: the area depends on the order of axes.
*/
export {};components/charts/radar-chart/radar-chart.tsx
"use client";
import type { CSSProperties } from "react";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import type { ChartSeries } from "../_kernel/encode";
import { formatExact, formatTick } from "../_kernel/format";
import { radarGeometry, radarTarget, type RadarAxis } from "../_kernel/radar";
import chart from "../_shared/chart.module.css";
import { SeriesLegend } from "../_shared/series-legend";
import { SeriesTable } from "../_shared/series-table";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./radar-chart.module.css";
export type { RadarAxis };
export type RadarChartProps = {
/** Names the chart. */
label: string;
/** One per spoke, 3–8, each with every series' value. */
axes: readonly RadarAxis[];
/** One to three series; more overlap into noise. */
series: readonly ChartSeries[];
/** The value at the rim; default a round number above the largest. Every
* axis shares it: a radar only compares when the scale is common. */
max?: number;
formatValue?: (value: number) => string;
formatTick?: (value: number) => string;
/** Default: shapes grow out from the centre, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
/** Profiles across a handful of attributes on one common scale. Read the
* shape, not the area — the area depends on the order of the axes. */
export function RadarChart({
label,
axes,
series,
max,
formatValue = formatExact,
formatTick: tickFormat = formatTick,
animation,
className,
}: RadarChartProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "pop",
axis: "y",
});
const model = radarTarget(axes, series, max);
const shown = useTweened(model.target, update);
if (axes.length < 3 || !series.length)
return (
<div className={cn(chart.root, className)}>
<p className={chart.empty}>
{axes.length
? "A radar needs at least three axes."
: "No data to display."}
</p>
</div>
);
const geometry = radarGeometry(shown, axes, series, model.step, tickFormat);
return (
<div {...rootProps} className={cn(chart.root, className)}>
{series.length > 1 ? <SeriesLegend series={series} lines /> : null}
{model.measured ? (
<div className={styles.frame}>
<div className={styles.plot}>
<svg
className={styles.svg}
viewBox="0 0 100 100"
role="img"
aria-label={`${label}. Exact values are in the data table.`}
>
{geometry.rings.map((ring) => (
<path key={ring.value} className={styles.ring} d={ring.d} />
))}
{geometry.spokes.map((spoke) => (
<line
key={spoke.key}
className={styles.spoke}
x1={50}
y1={50}
x2={spoke.x2}
y2={spoke.y2}
/>
))}
{geometry.polygons.map((polygon) => (
<g
key={polygon.key}
data-mark
style={{ "--series": polygon.color } as CSSProperties}
>
<path
className={styles.area}
data-open={!polygon.complete || undefined}
data-line={polygon.line}
d={polygon.path}
/>
{polygon.dots.map((dot) => (
<path key={dot.key} className={styles.dot} d={dot.d} />
))}
</g>
))}
</svg>
<div className={styles.labels} aria-hidden="true">
{geometry.spokes.map((spoke) => (
<span
key={spoke.key}
data-align={spoke.align}
style={{ left: `${spoke.lx}%`, top: `${spoke.ly}%` }}
>
{spoke.label}
</span>
))}
{geometry.rings.map((ring) => (
<span
key={ring.value}
data-ring
style={{ left: `${ring.labelX}%`, top: `${ring.labelY}%` }}
>
{ring.label}
</span>
))}
</div>
</div>
</div>
) : (
<p className={chart.empty}>Measurements unavailable.</p>
)}
<SeriesTable
label={label}
data={axes}
series={series}
formatValue={formatValue}
labelHeading="Attribute"
/>
</div>
);
}components/charts/radar-chart/radar-chart.module.css
@layer primitive {
/* Room around the plot for the axis labels. */
.frame {
width: min(100%, 30rem);
margin-inline: auto;
padding: var(--space-6) 5.5rem;
}
.plot {
position: relative;
aspect-ratio: 1;
}
.svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
/* Series grow from the chart's centre, not their own. */
.plot .svg [data-mark] {
transform-box: view-box;
transform-origin: 50% 50%;
}
.ring,
.spoke {
fill: none;
stroke: var(--chart-grid, var(--line));
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.area {
fill: color-mix(in oklab, var(--series) 18%, transparent);
stroke: var(--series);
stroke-linejoin: round;
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.area[data-open] {
fill: none;
}
.area[data-line="dashed"] {
stroke-dasharray: 6 4;
}
.area[data-line="dotted"] {
stroke-dasharray: 0.5 4.5;
}
.dot {
stroke: var(--series);
stroke-linecap: round;
stroke-width: 6;
vector-effect: non-scaling-stroke;
}
.labels {
position: absolute;
inset: 0;
color: var(--ink-2);
font-size: var(--text-12);
pointer-events: none;
}
.labels span {
position: absolute;
white-space: nowrap;
transform: translate(-50%, -50%);
}
.labels span[data-align="start"] {
transform: translate(0, -50%);
}
.labels span[data-align="end"] {
transform: translate(-100%, -50%);
}
.labels span[data-ring] {
color: var(--chart-label, var(--ink-3));
font-family: var(--font-mono);
font-size: var(--text-11);
transform: translate(-50%, -50%);
}
}components/charts/_kernel/radar.ts
import { seriesColor, type ChartSeries } from "./encode";
import { isValue, niceDomain, px, ticksBy } from "./scale";
/* RadarChart: one spoke per axis, all on ONE scale from zero. */
/** One axis (spoke) and each series' value on it. */
export type RadarAxis = {
key: string;
label: string;
values: Readonly<Record<string, number | null | undefined>>;
};
const cell = (series: string, axis: number) => `${series}|${axis}`;
export const RADAR_R = 40; // in a 100 × 100 viewBox, centred
export function radarTarget(
axes: readonly RadarAxis[],
series: readonly ChartSeries[],
max?: number,
) {
const target: Record<string, number> = {};
const all: number[] = [];
axes.forEach((axis, a) =>
series.forEach((entry) => {
const v = axis.values[entry.key];
if (isValue(v) && v >= 0) {
target[cell(entry.key, a)] = v;
all.push(v);
}
}),
);
const { domain, step } = niceDomain(max !== undefined ? [max] : all, {
count: 4,
});
target.__max = domain[1];
return { target, step, measured: all.length > 0 };
}
/** Where on the plot a value on axis `a` sits: angle from 12 o'clock. */
function point(a: number, count: number, value: number, max: number) {
const angle = (a / count) * 2 * Math.PI - Math.PI / 2;
const r = max > 0 ? (Math.min(value, max) / max) * RADAR_R : 0;
return [50 + r * Math.cos(angle), 50 + r * Math.sin(angle)] as const;
}
export function radarGeometry(
shown: Readonly<Record<string, number>>,
axes: readonly RadarAxis[],
series: readonly ChartSeries[],
step: number,
format: (value: number) => string,
) {
const n = axes.length;
const max = shown.__max;
const rings = ticksBy([0, max], step)
.filter((v) => v > 0)
.map((value) => ({
value,
label: format(value),
d:
axes
.map((_, a) => {
const [x, y] = point(a, n, value, max);
return `${a ? "L" : "M"}${px(x)},${px(y)}`;
})
.join("") + "Z",
// Between the first two spokes, where no axis label sits.
...(() => {
const angle = (0.5 / n) * 2 * Math.PI - Math.PI / 2;
const r = (value / max) * RADAR_R;
return {
labelX: 50 + r * Math.cos(angle),
labelY: 50 + r * Math.sin(angle),
};
})(),
}));
const spokes = axes.map((axis, a) => {
const [x, y] = point(a, n, max, max);
const angle = (a / n) * 2 * Math.PI - Math.PI / 2;
return {
key: axis.key,
label: axis.label,
x2: px(x),
y2: px(y),
// Labels sit just outside the rim, anchored away from the centre.
lx: 50 + Math.cos(angle) * (RADAR_R + 5),
ly: 50 + Math.sin(angle) * (RADAR_R + 5),
align:
Math.abs(Math.cos(angle)) < 0.2
? "middle"
: Math.cos(angle) > 0
? "start"
: "end",
};
});
const polygons = series.map((entry, s) => {
const points = axes.map((_, a) => {
const v = shown[cell(entry.key, a)];
return v === undefined ? null : point(a, n, v, max);
});
const complete = points.every(Boolean);
let path = "";
if (complete) {
path =
points
.map((p, a) => `${a ? "L" : "M"}${px(p![0])},${px(p![1])}`)
.join("") + "Z";
} else {
// An unmeasured axis breaks the outline: no fill, no guessed vertex.
points.forEach((p, a) => {
const next = points[(a + 1) % n];
if (p && next)
path += `M${px(p[0])},${px(p[1])}L${px(next[0])},${px(next[1])}`;
});
}
return {
key: entry.key,
color: seriesColor(entry, s),
line: entry.line,
complete,
path,
dots: points.flatMap((p, a) =>
p ? [{ key: String(a), d: `M${px(p[0])},${px(p[1])}h0` }] : [],
),
};
});
return { rings, spokes, polygons };
}WaterfallChart
totals · signed changes · a missing change
Where this month's recurring revenue came from and went.
- Total
- Increase
- Decrease
View data for Monthly recurring revenue bridge, thousands
| Step | Change | Running total |
|---|---|---|
| Start of month | — | $84.2k |
| New | +$12.4k | $96.6k |
| Expansion | +$6.1k | $102.7k |
| Contraction | −$2.3k | $100.4k |
| Churn | −$5.8k | $94.6k |
| End of month | — | $94.6k |
A missing change is not a zero. Hide the churn reading: the end of month becomes unavailable rather than quietly overstated.
Sourcecomponents/charts/waterfall-chart/doc.ts · components/charts/waterfall-chart/waterfall-chart.tsx · components/charts/_kernel/waterfall.ts
components/charts/waterfall-chart/doc.ts
/**
* WaterfallChart — a level, the changes that move it, and where it lands.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED
* steps { key, label, value: number | null, total?: boolean }[]
* formatValue?, formatTick?, height?, animation? (default: grow)
* inspect? read steps by pointer or keyboard; default true
*
* # Behaviour
*
* R1 A total stands on zero; a total left null is the running sum so far.
* R2 A change floats from the running total to the new one; increases and
* decreases differ in colour, and every change prints its sign.
* R3 Dashed connectors join each bar's end to the next bar's start.
* R4 An unmeasured change is never skipped as zero: it and every level
* after it (until a given total) are unavailable.
* R5 The axis includes zero. The table lists each change and the running
* total after it.
* R6 Inspection (inspect, default on), as in LineChart: each step reads
* its change and the running total it lands on; a total reads its level;
* past an unmeasured change the running total reads as unknown.
*/
export {};components/charts/waterfall-chart/waterfall-chart.tsx
"use client";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { yAxis } from "../_kernel/cartesian";
import { waterfallReadout } from "../_kernel/inspect";
import { formatExact, formatTick } from "../_kernel/format";
import { band, niceDomain, PLOT, px } from "../_kernel/scale";
import { waterfallBars, type WaterfallStep } from "../_kernel/waterfall";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./waterfall-chart.module.css";
export type { WaterfallStep };
export type WaterfallChartProps = {
/** Names the chart. */
label: string;
/** In order: levels (total: true) and the changes between them. */
steps: readonly WaterfallStep[];
formatValue?: (value: number) => string;
formatTick?: (value: number) => string;
height?: string;
/** Default: bars grow from where they start, when scrolled into view. */
animation?: AnimationProp;
/** Read values position by position, by pointer or keyboard (default). */
inspect?: boolean;
className?: string;
};
const signed = (format: (v: number) => string) => (v: number) =>
v > 0 ? `+${format(v)}` : v < 0 ? `−${format(-v)}` : format(v);
/** A level, the changes that move it, and where it lands: an MRR bridge, a
* budget. Each change floats from the running total; totals stand on zero.
* An unmeasured change leaves every later level unknown. */
export function WaterfallChart({
label,
steps,
formatValue = formatExact,
formatTick: tickFormat = formatTick,
height,
animation,
inspect = true,
className,
}: WaterfallChartProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "grow",
axis: "y",
});
const bars = waterfallBars(steps);
const levels = bars.flatMap((bar) =>
bar.from === null || bar.to === null ? [] : [bar.from, bar.to],
);
const { domain, step } = niceDomain(levels);
const target: Record<string, number> = { __lo: domain[0], __hi: domain[1] };
for (const bar of bars)
if (bar.from !== null && bar.to !== null) {
target[`${bar.key}|from`] = bar.from;
target[`${bar.key}|to`] = bar.to;
}
const shown = useTweened(target, update);
if (!steps.length)
return (
<div className={cn(chart.root, className)}>
<p className={chart.empty}>No data to display.</p>
</div>
);
const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, tickFormat);
const slots = band(bars.length, 0.25);
const drawn = bars.flatMap((bar, index) => {
const from = shown[`${bar.key}|from`];
const to = shown[`${bar.key}|to`];
if (from === undefined || to === undefined) return [];
return [
{
bar,
index,
top: Math.min(y(from), y(to)),
bottom: Math.max(y(from), y(to)),
},
];
});
const change = signed(formatValue);
return (
<div {...rootProps} className={cn(chart.root, className)}>
<ChartLegend
items={[
{ key: "total", label: "Total", color: "var(--chart-neutral)" },
{ key: "increase", label: "Increase", color: "var(--chart-pos)" },
{ key: "decrease", label: "Decrease", color: "var(--chart-neg)" },
]}
/>
{levels.length ? (
<CartesianPlot
label={label}
ticks={ticks}
height={height}
zeroAt={shown.__lo < 0 ? px(y(0)) : undefined}
wrap={slots.step / PLOT}
overlay={
inspect ? (
<ChartInspector
label={label}
positions={bars.map((_, index) => px(slots.centre(index)))}
band={px(slots.step)}
readout={(index) => waterfallReadout(bars[index], formatValue)}
/>
) : null
}
xLabels={bars.map((bar, index) => ({
key: bar.key,
at: px(slots.centre(index)),
label: bar.label,
align: "middle" as const,
tier: 2 as const,
}))}
notes={drawn.map(({ bar, index, top }) => ({
key: bar.key,
x: slots.centre(index) / PLOT,
y: top / PLOT,
text:
bar.kind === "total"
? formatValue(bar.value as number)
: change(bar.value as number),
}))}
>
{drawn.map(({ bar, index }, i) => {
const next = drawn[i + 1];
return next && next.index === index + 1 && bar.to !== null ? (
<line
key={`c${bar.key}`}
className={styles.connector}
x1={px(slots.start(index) + slots.width)}
x2={px(slots.start(index + 1))}
y1={px(y(shown[`${bar.key}|to`]))}
y2={px(y(shown[`${bar.key}|to`]))}
/>
) : null;
})}
{drawn.map(({ bar, index, top, bottom }) => (
<rect
key={bar.key}
data-mark
data-kind={bar.kind}
className={styles.bar}
x={px(slots.start(index))}
width={px(slots.width)}
y={px(top)}
height={px(Math.max(1, bottom - top))}
/>
))}
</CartesianPlot>
) : (
<p className={chart.empty}>Measurements unavailable.</p>
)}
<ChartData label={label}>
<THead>
<Tr>
<Th>Step</Th>
<Th numeric>Change</Th>
<Th numeric>Running total</Th>
</Tr>
</THead>
<TBody>
{bars.map((bar) => (
<Tr key={bar.key}>
<Th scope="row">{bar.label}</Th>
<Td numeric>
{bar.kind === "total"
? "—"
: bar.value !== null
? change(bar.value)
: "Unavailable"}
</Td>
<Td numeric>
{bar.to !== null ? formatValue(bar.to) : "Unavailable"}
</Td>
</Tr>
))}
</TBody>
</ChartData>
</div>
);
}components/charts/_kernel/waterfall.ts
import { isValue } from "./scale";
/* WaterfallChart: a start, the changes, and where they land. */
export type WaterfallStep = {
key: string;
label: string;
/** A change, or (total: true) a level. A total left null is the running
* sum so far. */
value: number | null;
total?: boolean;
};
export type WaterfallBar = {
key: string;
label: string;
kind: "total" | "increase" | "decrease" | "unavailable";
/** Where the bar runs from and to; null when it cannot be known. */
from: number | null;
to: number | null;
/** The change (or level, for totals) as given or computed. */
value: number | null;
};
/** Running totals through the steps. An unmeasured change makes every
* later level unknown — it is never skipped as zero. */
export function waterfallBars(steps: readonly WaterfallStep[]): WaterfallBar[] {
let level: number | null = 0;
return steps.map((step) => {
if (step.total) {
const value = isValue(step.value) ? step.value : level;
if (isValue(step.value)) level = step.value;
return {
key: step.key,
label: step.label,
kind: value === null ? "unavailable" : "total",
from: value === null ? null : 0,
to: value,
value,
};
}
if (!isValue(step.value) || level === null) {
level = null;
return {
key: step.key,
label: step.label,
kind: "unavailable",
from: null,
to: null,
value: isValue(step.value) ? step.value : null,
};
}
const from: number = level;
// Rounded to 12 significant digits: summing decimals must not print
// 94.60000000000001.
level = Number((from + step.value).toPrecision(12));
return {
key: step.key,
label: step.label,
kind: step.value >= 0 ? "increase" : "decrease",
from,
to: level,
value: step.value,
};
});
}CalendarHeatmap
a year · four levels · zero and no data
Every day of the last year. The outlined week in March had no data.
View data for Deploys per day, last 12 months
| Month | Total | Active days | Days with data | Busiest day |
|---|---|---|---|---|
| Sep 2025 | 6 | 3 | 4 | 2025-09-30 (4) |
| Oct 2025 | 63 | 21 | 31 | 2025-10-10 (5) |
| Nov 2025 | 70 | 22 | 30 | 2025-11-04 (6) |
| Dec 2025 | 67 | 23 | 31 | 2025-12-12 (7) |
| Jan 2026 | 79 | 21 | 31 | 2026-01-07 (8) |
| Feb 2026 | 109 | 24 | 28 | 2026-02-24 (9) |
| Mar 2026 | 64 | 17 | 24 | 2026-03-17 (9) |
| Apr 2026 | 116 | 27 | 30 | 2026-04-20 (10) |
| May 2026 | 93 | 21 | 31 | 2026-05-13 (10) |
| Jun 2026 | 127 | 25 | 30 | 2026-06-10 (11) |
| Jul 2026 | 160 | 26 | 31 | 2026-07-10 (12) |
| Aug 2026 | 126 | 27 | 31 | 2026-08-12 (13) |
| Sep 2026 | 133 | 22 | 26 | 2026-09-03 (13) |
Zero and no data differ. A day measured as nothing is filled with the empty level; a day with no data is outlined. The picture is named with its summary, and the table has each month.
Sourcecomponents/charts/calendar-heatmap/doc.ts · components/charts/calendar-heatmap/calendar-heatmap.tsx · components/charts/calendar-heatmap/calendar-heatmap.module.css · components/charts/_kernel/calendar.ts
components/charts/calendar-heatmap/doc.ts
/**
* CalendarHeatmap — a span of days, a week to a column.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED
* days { date: "YYYY-MM-DD", value: number | null }[]
* from, to the first and last day shown
* unit? what a day's number counts, for the summary
* formatValue?, animation? (default: fade)
*
* # Behaviour
*
* R1 Weeks run left to right, Monday at the top. Dates are UTC calendar
* days, so every time zone draws the same grid.
* R2 Four states: a value (one of four levels, by quartile of the busy
* days); zero (filled, the empty level); no data — a day with no entry
* or a null (outlined); and outside the range (not drawn).
* R3 The picture is named with a summary: total, active days, and the
* busiest day. Each day carries its date and value as a tooltip.
* R4 The table summarises by month: total, active days, days with data,
* and the busiest day.
*/
export {};components/charts/calendar-heatmap/calendar-heatmap.tsx
"use client";
import type { CSSProperties } from "react";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { cn } from "@/lib/utils/cn";
import {
calendarCells,
monthLabels,
monthSummary,
type CalendarDay,
} from "../_kernel/calendar";
import { sequentialFill } from "../_kernel/encode";
import { formatExact } from "../_kernel/format";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./calendar-heatmap.module.css";
export type { CalendarDay };
export type CalendarHeatmapProps = {
/** Names the chart. */
label: string;
/** One entry per day, as YYYY-MM-DD. A day with no entry has no data —
* not zero; send 0 for a day measured as nothing. */
days: readonly CalendarDay[];
/** The first and last day shown, YYYY-MM-DD (UTC calendar days). */
from: string;
to: string;
/** What a day's number is, for the summary: "deploys". */
unit?: string;
formatValue?: (value: number) => string;
/** Default: days fade in, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
const WEEKDAYS = ["Mon", "", "Wed", "", "Fri", "", ""];
/** A year (or any span) of days, a week to a column: activity at a glance.
* Four levels by quartile of the busy days; a measured zero is filled, a
* day without data is outlined. The exact numbers are summarised by month
* in the table. */
export function CalendarHeatmap({
label,
days,
from,
to,
unit = "",
formatValue = formatExact,
animation,
className,
}: CalendarHeatmapProps) {
const { rootProps } = useChartMotion(animation, { enter: "fade", axis: "y" });
const { cells } = calendarCells(days, from, to);
const months = monthLabels(cells);
const summary = monthSummary(cells);
const inRange = cells.filter((c) => c.state !== "outside");
const total = inRange.reduce((sum, c) => sum + (c.value ?? 0), 0);
const active = inRange.filter((c) => (c.value ?? 0) > 0).length;
const busiest = inRange.reduce<(typeof cells)[number] | null>(
(best, c) =>
c.value !== null && (!best || c.value > (best.value ?? 0)) ? c : best,
null,
);
const suffix = unit ? ` ${unit}` : "";
const description = `${label}: ${formatValue(total)}${suffix} over ${active} active days${
busiest?.value
? `; busiest ${busiest.date} with ${formatValue(busiest.value)}`
: ""
}.`;
return (
<div {...rootProps} className={cn(chart.root, className)}>
<div className={styles.scroll}>
<div className={styles.calendar} role="img" aria-label={description}>
<div className={styles.months} aria-hidden="true">
{months.map((month) => (
<span
key={month.week}
style={{ "--week": month.week } as CSSProperties}
>
{month.label}
</span>
))}
</div>
<div className={styles.days} aria-hidden="true">
{WEEKDAYS.map((day, index) => (
<span key={index}>{day}</span>
))}
</div>
<div className={styles.grid} aria-hidden="true">
{cells.map((cell) => (
<span
key={cell.date}
data-mark={
cell.state === "value" || cell.state === "zero" || undefined
}
data-state={cell.state}
className={styles.day}
title={
cell.state === "outside"
? undefined
: `${cell.date}: ${cell.value === null ? "no data" : formatValue(cell.value)}${suffix}`
}
style={
cell.state === "value"
? ({
"--fill": sequentialFill(cell.level / 4),
} as CSSProperties)
: undefined
}
/>
))}
</div>
</div>
</div>
<div className={styles.key} aria-hidden="true">
Less
{[1, 2, 3, 4].map((level) => (
<span
key={level}
className={styles.day}
style={{ "--fill": sequentialFill(level / 4) } as CSSProperties}
/>
))}
More
<span className={styles.gap} />
<span className={styles.day} data-state="zero" /> None
<span className={styles.day} data-state="missing" /> No data
</div>
<ChartData label={label}>
<THead>
<Tr>
<Th>Month</Th>
<Th numeric>Total</Th>
<Th numeric>Active days</Th>
<Th numeric>Days with data</Th>
<Th>Busiest day</Th>
</Tr>
</THead>
<TBody>
{summary.map((month) => (
<Tr key={month.month}>
<Th scope="row">{month.month}</Th>
<Td numeric>{formatValue(month.total)}</Td>
<Td numeric>{month.active}</Td>
<Td numeric>{month.measured}</Td>
<Td>
{month.best?.value
? `${month.best.date} (${formatValue(month.best.value)})`
: "—"}
</Td>
</Tr>
))}
</TBody>
</ChartData>
</div>
);
}components/charts/calendar-heatmap/calendar-heatmap.module.css
@layer primitive {
.scroll {
min-width: 0;
overflow-x: auto;
padding-bottom: var(--space-2);
}
.calendar {
--cell: 11px;
--gap: 3px;
display: grid;
width: max-content;
grid-template-columns: auto auto;
column-gap: var(--space-3);
color: var(--chart-label, var(--ink-3));
font-size: var(--text-11);
}
.months {
position: relative;
height: 1.4em;
grid-column: 2;
}
.months span {
position: absolute;
top: 0;
left: calc(var(--week) * (var(--cell) + var(--gap)));
}
.days {
display: grid;
grid-template-rows: repeat(7, var(--cell));
gap: var(--gap);
line-height: var(--cell);
}
.grid {
display: grid;
grid-auto-columns: var(--cell);
grid-auto-flow: column;
grid-template-rows: repeat(7, var(--cell));
gap: var(--gap);
}
.day {
border-radius: 2px;
background: var(--fill, var(--chart-absent));
}
.day[data-state="zero"] {
--fill: var(--chart-absent);
}
.day[data-state="missing"] {
border: 1px dashed var(--ink-4, var(--line-strong));
background: transparent;
}
.day[data-state="outside"] {
visibility: hidden;
}
.key {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
color: var(--ink-3);
font-size: var(--text-11);
}
.key .day {
display: inline-block;
width: 11px;
height: 11px;
}
.key .gap {
width: var(--space-5);
}
}components/charts/_kernel/calendar.ts
import { isValue } from "./scale";
/* CalendarHeatmap: days in week columns, Monday first. All dates are UTC
calendar days, so server and browser agree whatever their time zones. */
export type CalendarDay = { date: string; value: number | null };
export type CalendarCell = {
date: string;
/** 0 Monday … 6 Sunday. */
weekday: number;
week: number;
state: "value" | "zero" | "missing" | "outside";
value: number | null;
/** 1–4 for values: which quartile of the non-zero values. */
level: number;
};
const DAY = 86_400_000;
const parse = (date: string) => Date.parse(`${date}T00:00:00Z`);
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
const weekday = (ms: number) => (new Date(ms).getUTCDay() + 6) % 7;
/** Every day from `from` to `to`, padded to whole weeks. Days in range with
* no entry are "missing" (no data); padding days are "outside". */
export function calendarCells(
days: readonly CalendarDay[],
from: string,
to: string,
) {
const byDate = new Map(days.map((d) => [d.date, d.value]));
const start = parse(from);
const end = parse(to);
const first = start - weekday(start) * DAY;
const last = end + (6 - weekday(end)) * DAY;
const positives = days
.map((d) => d.value)
.filter((v): v is number => isValue(v) && v > 0)
.toSorted((a, b) => a - b);
const cut = (p: number) =>
positives.length
? positives[
Math.min(positives.length - 1, Math.floor(p * positives.length))
]
: 0;
const cuts = [cut(0.25), cut(0.5), cut(0.75)];
const cells: CalendarCell[] = [];
for (let ms = first, i = 0; ms <= last; ms += DAY, i++) {
const date = iso(ms);
const inRange = ms >= start && ms <= end;
const raw = byDate.get(date);
const has = byDate.has(date) && isValue(raw);
const value = has ? (raw as number) : null;
cells.push({
date,
weekday: weekday(ms),
week: Math.floor(i / 7),
state: !inRange
? "outside"
: !has
? "missing"
: value === 0
? "zero"
: "value",
value,
level: value && value > 0 ? 1 + cuts.filter((c) => value > c).length : 0,
});
}
return { cells, weeks: Math.ceil(cells.length / 7) };
}
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
/** A label over the first week column of each month. */
export function monthLabels(cells: readonly CalendarCell[]) {
const out: { week: number; label: string }[] = [];
let seen = "";
for (const cell of cells) {
if (cell.state === "outside" || cell.weekday !== 0) continue;
const month = cell.date.slice(0, 7);
if (month !== seen) {
seen = month;
out.push({
week: cell.week,
label: MONTHS[Number(cell.date.slice(5, 7)) - 1],
});
}
}
// A month that starts too close to the next (a partial first month) would
// print on top of it.
return out.filter(
(label, i) => !out[i + 1] || out[i + 1].week - label.week >= 3,
);
}
/** Per-month summary for the data table. */
export function monthSummary(cells: readonly CalendarCell[]) {
const months = new Map<
string,
{
total: number;
active: number;
measured: number;
best: CalendarCell | null;
}
>();
for (const cell of cells) {
if (cell.state === "outside") continue;
const key = cell.date.slice(0, 7);
const m = months.get(key) ?? {
total: 0,
active: 0,
measured: 0,
best: null,
};
if (cell.value !== null) {
m.measured++;
m.total += cell.value;
if (cell.value > 0) m.active++;
if (!m.best || cell.value > (m.best.value ?? 0)) m.best = cell;
}
months.set(key, m);
}
return [...months].map(([month, m]) => ({
month: `${MONTHS[Number(month.slice(5, 7)) - 1]} ${month.slice(0, 4)}`,
...m,
}));
}SunburstChart
a hierarchy · a hue per branch
By team, then by project.
- Data380 GB · 39%
- Platform420 GB · 43.1%
- Growth105 GB · 10.8%
- Design70 GB · 7.2%
View data for Storage by team and project, GB
| Part | Value | Share of total |
|---|---|---|
| Data | 380 GB | 39% |
| Data / Warehouse | 300 GB | 30.8% |
| Data / Exports | 80 GB | 8.2% |
| Platform | 420 GB | 43.1% |
| Platform / atlas-db | 210 GB | 21.5% |
| Platform / atlas-api | 120 GB | 12.3% |
| Platform / Logs | 90 GB | 9.2% |
| Growth | 105 GB | 10.8% |
| Growth / Website | 60 GB | 6.2% |
| Growth / Experiments | 45 GB | 4.6% |
| Design | 70 GB | 7.2% |
| Design / Assets | 70 GB | 7.2% |
Sourcecomponents/charts/sunburst-chart/doc.ts · components/charts/sunburst-chart/sunburst-chart.tsx · components/charts/_kernel/sunburst.ts
components/charts/sunburst-chart/doc.ts
/**
* SunburstChart — parts of a whole, and the parts of those parts.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED
* data { key, label, value?, children? }[] — the top level
* totalLabel?, formatValue?, animation? (default: trace)
*
* # Behaviour
*
* R1 The top level is the inner ring; each child sits outside its parent,
* spanning its share of the parent. Up to three rings are drawn.
* R2 A parent's value is the sum of its children; only leaves carry
* values. Non-positive or non-finite leaves count as nothing.
* R3 Each top-level part takes a hue (four, then neutral) and its
* descendants take lighter tints of it, so a branch reads as one family.
* R4 The legend lists the top level with values and shares; the table
* lists every node by its full path.
*/
export {};components/charts/sunburst-chart/sunburst-chart.tsx
"use client";
import type { CSSProperties } from "react";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { formatExact, formatPercent } from "../_kernel/format";
import {
sunburstArcs,
sunburstPath,
sunburstRing,
sunburstRows,
sunburstTarget,
type SunburstNode,
} from "../_kernel/sunburst";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./sunburst-chart.module.css";
export type { SunburstNode };
export type SunburstChartProps = {
/** Names the chart. */
label: string;
/** The top level; children nest outward, up to three rings. */
data: readonly SunburstNode[];
/** Under the total in the middle. */
totalLabel?: string;
formatValue?: (value: number) => string;
/** Default: arcs trace round, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
const depthOf = (nodes: readonly SunburstNode[]): number =>
nodes.length
? 1 + Math.max(...nodes.map((n) => depthOf(n.children ?? [])))
: 0;
/** Parts of a whole, and the parts of those parts: storage by team and
* then by project. Each top-level part keeps its hue as it splits. The
* legend names the top level; the table has every level. */
export function SunburstChart({
label,
data,
totalLabel = "Total",
formatValue = formatExact,
animation,
className,
}: SunburstChartProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "trace",
axis: "x",
});
const shown = useTweened(sunburstTarget(data), update);
const depths = Math.min(3, depthOf(data));
const { arcs, total } = sunburstArcs(data, shown, depths);
const exactTotal = sunburstRows(data, 0)
.filter((row) => row.depth === 0)
.reduce((sum, row) => sum + row.value, 0);
const rows = sunburstRows(data, exactTotal);
return (
<div {...rootProps} className={cn(chart.root, styles.wrap, className)}>
{!data.length ? (
<p className={chart.empty}>No data to display.</p>
) : (
<div className={styles.layout} role="group" aria-label={label}>
<div className={styles.ring}>
<svg
className={styles.svg}
viewBox="0 0 100 100"
aria-hidden="true"
>
{arcs.map((arc) => (
<path
key={arc.path}
data-mark
className={styles.arc}
d={sunburstPath(arc, depths)}
pathLength={1}
strokeWidth={sunburstRing(arc.depth, depths).width}
style={{ "--series": arc.color } as CSSProperties}
/>
))}
</svg>
<div className={styles.centre} aria-hidden="true">
<span className={styles.total}>
{total > 0 ? formatValue(exactTotal) : "—"}
</span>
<span className={styles.caption}>{totalLabel}</span>
</div>
</div>
<ChartLegend
className={styles.legend}
layout="list"
label={`${label}: ${totalLabel.toLowerCase()} ${formatValue(exactTotal)}`}
items={data.map((node) => {
const value =
rows.find((row) => row.depth === 0 && row.key === node.key)
?.value ?? 0;
return {
key: node.key,
label: node.label,
color:
arcs.find((arc) => arc.path === node.key)?.color ??
"var(--chart-neutral)",
value: formatValue(value),
note:
exactTotal > 0
? formatPercent(value / exactTotal)
: undefined,
};
})}
/>
</div>
)}
{data.length ? (
<ChartData label={label}>
<THead>
<Tr>
<Th>Part</Th>
<Th numeric>Value</Th>
<Th numeric>Share of total</Th>
</Tr>
</THead>
<TBody>
{rows.map((row) => (
<Tr key={row.path}>
<Th scope="row">{row.path}</Th>
<Td numeric>{formatValue(row.value)}</Td>
<Td numeric>
{row.share !== null ? formatPercent(row.share) : "—"}
</Td>
</Tr>
))}
</TBody>
</ChartData>
) : null}
</div>
);
}components/charts/_kernel/sunburst.ts
import { arcPath } from "./donut";
import { colorVar, type ChartColor } from "./encode";
import { isValue } from "./scale";
/* SunburstChart: a hierarchy as rings, parents inside their children. */
export type SunburstNode = {
key: string;
label: string;
/** Leaves carry a value; a parent's value is the sum of its children. */
value?: number | null;
children?: readonly SunburstNode[];
};
export type SunburstArc = {
/** Keys from the top level down, joined with "/". */
path: string;
label: string;
depth: number;
value: number;
color: string;
/** Turns. */
start: number;
end: number;
};
const valueOf = (node: SunburstNode): number => {
if (node.children?.length)
return node.children.reduce((sum, c) => sum + valueOf(c), 0);
return isValue(node.value) && node.value > 0 ? node.value : 0;
};
/** Every node's value by path, for tweening. */
export function sunburstTarget(
nodes: readonly SunburstNode[],
prefix = "",
): Record<string, number> {
const out: Record<string, number> = {};
for (const node of nodes) {
const path = prefix ? `${prefix}/${node.key}` : node.key;
out[path] = valueOf(node);
if (node.children?.length)
Object.assign(out, sunburstTarget(node.children, path));
}
return out;
}
/** Arcs for the tweened values. Each top-level node takes a hue (four, then
* neutral); its descendants take lighter tints of it by depth. */
export function sunburstArcs(
nodes: readonly SunburstNode[],
shown: Readonly<Record<string, number>>,
maxDepth = 3,
) {
const arcs: SunburstArc[] = [];
const walk = (
level: readonly SunburstNode[],
prefix: string,
depth: number,
start: number,
span: number,
parentTotal: number,
hue: string | null,
) => {
let cursor = start;
level.forEach((node, index) => {
const path = prefix ? `${prefix}/${node.key}` : node.key;
const value = shown[path] ?? 0;
const share = parentTotal > 0 ? (value / parentTotal) * span : 0;
const base =
hue ?? colorVar(index < 4 ? ((index + 1) as ChartColor) : "neutral");
const color =
depth === 0
? base
: `color-mix(in oklab, ${base} ${100 - depth * 28}%, var(--surface-panel))`;
if (share > 0) {
arcs.push({
path,
label: node.label,
depth,
value,
color,
start: cursor,
end: cursor + share,
});
if (node.children?.length && depth + 1 < maxDepth)
walk(node.children, path, depth + 1, cursor, share, value, base);
}
cursor += share;
});
};
const total = nodes.reduce((sum, n) => sum + (shown[n.key] ?? 0), 0);
walk(nodes, "", 0, 0, 1, total, null);
return { arcs, total };
}
/** A ring's radius and width by depth, in a 100 × 100 viewBox. */
export function sunburstRing(depth: number, depths: number) {
const inner = 16;
const outer = 47;
const width = (outer - inner) / depths;
return { radius: inner + width * (depth + 0.5), width: width - 1 };
}
export const sunburstPath = (arc: SunburstArc, depths: number) => {
const { radius } = sunburstRing(arc.depth, depths);
const gap = arc.end - arc.start > 0.006 ? 0.0015 : 0;
return arcPath(arc.start + gap, arc.end - gap, radius);
};
/** Every node as rows for the data table: its path, value, and share of
* the whole. */
export function sunburstRows(nodes: readonly SunburstNode[], total: number) {
const rows: {
key: string;
path: string;
label: string;
depth: number;
value: number;
share: number | null;
}[] = [];
const walk = (
level: readonly SunburstNode[],
prefix: string,
depth: number,
) => {
for (const node of level) {
const label = prefix ? `${prefix} / ${node.label}` : node.label;
const value = valueOf(node);
rows.push({
key: node.key,
path: label,
label: node.label,
depth,
value,
share: total > 0 ? value / total : null,
});
if (node.children?.length) walk(node.children, label, depth + 1);
}
};
walk(nodes, "", 0);
return rows;
}