// ~/src/siteplane/components/pie-chart/pie-chart.tsx "use client"; import * as React from "react"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { ChartEmptyState } from "../chart/chart-cartesian"; import { CHART_DEFAULT_LOCALE, chartFigureFormatter, chartLabels, chartNumberFormatter, } from "../chart/chart-format"; import { useChartInteraction } from "../chart/chart-interaction"; import { ChartLegend, useChartSeriesFocus } from "../chart/chart-legend"; import { chartEmptyColor, chartRingColors } from "../chart/chart-palette"; import { ChartTooltip, chartTooltipText } from "../chart/chart-tooltip"; import type { ChartLabels, ChartPalette } from "../chart/chart-types"; import { MotionNumber, followed, useFollowPoint, useTweenedRows, } from "../motion"; import { DONUT_INNER_RADIUS, PIE_BOX, pieCenterFit, popDistance, popOffset, ringOf, ringPath, shareAnchor, shareAtPoint, wedgePath, type PieArc, type PieCenterStep, } from "./pie-geometry"; // A gap in the ring smaller than this, in percent of the circle, is the // rounding of shares that fill it: no track is drawn for it. const trackMinimum = 0.001; export type PieChartDatum = { id: string; label: string; value: number; color?: string; /** * An unfilled share, such as unused capacity. It takes the neutral empty * colour instead of a series colour. */ empty?: boolean; }; export type PieChartProps = { data: readonly PieChartDatum[]; label: string; /** * The id of an element that describes the chart, read after what the chart * gives assistive technology itself. */ "aria-describedby"?: string; /** * `brand` (the default) shades the brand colour from strong to light, spread * over the number of shares (up to eight), for parts of one whole. * `categorical` gives each share its own chart colour, `--chart-1` to * `--chart-8` in order. Both start over from the ninth share. A share that * names its `color` keeps it, and an `empty` share is always neutral. The * last share never takes the color of the first, where the ring closes. */ palette?: ChartPalette; size?: number; className?: string; /** Writes a value in the tooltip and in what screen readers read for each share. A number in the format of `locale` by default. */ formatValue?: (value: number) => string; /** The locale of the numbers. */ locale?: string; emptyLabel?: string; /** * Replaces words that the chart gives screen readers, merged with the * English defaults: its role description and the keyboard hint. */ labels?: Partial | undefined; /** * `donut` draws the shares as a ring. `pie` fills the whole disc with a wedge * per share, so it has no hole. */ variant?: "donut" | "pie"; /** * Content of your own in the hole of the donut, in place of `centerValue` * and `centerLabel`. It is laid out in the square around the hole and cut * off at the hole's edge, so it never covers the ring: keep it short, or pass * the figure as `centerValue`, which the chart fits. A pie has no hole and * shows none, and a chart with nothing to draw shows `emptyLabel` in its * place. It lies over the ring without taking the pointer and is not part of * the chart's accessible name. */ center?: React.ReactNode; /** * A figure in the hole of the donut, such as the total of the shares, which * the chart fits into the hole: in the largest text step it fits in, from 30 * px down, and in compact notation, such as 1.2M, when it does not fit in * full at 20 px. Screen readers read it in full. It rolls to a new value. */ centerValue?: number; /** A caption under `centerValue`, such as Tickets: one line of 12 px muted text that ends in an ellipsis when the hole is too narrow for it. */ centerLabel?: string; /** How `centerValue` is written, as Intl.NumberFormat options in `locale`, such as a currency. The chart adds compact notation when the figure needs it. */ centerFormat?: Intl.NumberFormatOptions; /** * Lists the shares beside the ring, each with its colour. Without it screen * readers still read each share and its value, as the description of the * chart. */ legend?: boolean; /** * Lets the highlighted share step out of the ring, 10 px along its middle * angle at any size, and lets a mouse over an entry of the legend highlight * its share as well. Off by default: no share ever moves, and the legend does * not react to the mouse. No share fades either way. A share that steps out * reaches up to 10 px beyond the box of the chart. */ popOut?: boolean; }; /** * One share of the ring, or one wedge of the pie. While it is the highlighted * share it steps out along its middle angle, and it goes back in when it is not; * a chart that does not pop out gives it no distance to step, so it stays. It * moves with the follow motion, which owns the glide, its token and reduced * motion (rulebook M5): the translate is a pair of motion values bound to the * path, so only the path moves, and the chart does not render again. No share * fades: the shares are shades of one hue, and a faded share took the colour of * another one. */ function PieSegment({ arc, color, container, highlighted, pie, popUnits, ring, }: { arc: PieArc; color: string; /** The element whose motion tokens set the glide: the chart. */ container: React.RefObject; /** This share is the highlighted one. */ highlighted: boolean; /** A wedge of a pie, not a share of the ring. */ pie: boolean; /** How far the highlighted share steps out, in the units of the box. */ popUnits: number; ring: { radius: number; width: number }; }): React.ReactElement | null { const out = useFollowPoint( highlighted ? popOffset(arc.start, arc.value, popUnits) : { x: 0, y: 0 }, { container }, ); if (pie) // A wedge is a path of its own, so that neighbours meet along exact edges; // dashes of a stroke as wide as the disc would meet a little off. return arc.value > 0 ? ( ) : null; // The share is a dash along the open path of the ring. return ( ); } // The text classes of the steps of a figure in the hole, written whole for // Tailwind: the kit's 30 px figure (the Metric Card's), then the smaller steps. const figureStepClassName: Record = { 12: "text-xs", 14: "text-sm", 16: "text-base", 20: "text-xl", 24: "text-2xl", 30: "text-3xl", }; /** * A figure in the hole of the donut, with an optional caption under it: in the * largest step it fits in, in full or in compact notation (`pieCenterFit`), * which the chart picks from its size and the text alone, so that the server * and the client agree. A compact figure is read in full. The caption stays on * one line and ends in an ellipsis where the hole narrows (rulebook K5). */ function PieCenterFigure({ caption, format, locale, size, value, }: { caption: string | undefined; format: Intl.NumberFormatOptions | undefined; locale: string; size: number; value: number; }): React.ReactElement { const full = chartFigureFormatter(locale, format)(value); const short = chartFigureFormatter(locale, format, true)(value); const fit = pieCenterFit({ caption, chartSize: size, compact: short, full }); const text = fit.compact ? short : full; return ( <> {text === full ? ( ) : ( <> {full} {caption ? ( {caption} ) : null} ); } export function PieChart({ data, label, "aria-describedby": describedBy, palette = "brand", size = 200, className, formatValue: givenFormat, locale = CHART_DEFAULT_LOCALE, emptyLabel = "No data", labels, variant = "donut", center, centerValue, centerLabel, centerFormat, legend = true, popOut = false, }: PieChartProps): React.ReactElement { const chart = React.useRef(null); const words = chartLabels(labels); // Numbers as the other charts write them: in the format of the locale. const formatValue = givenFormat ?? chartNumberFormatter(locale); const pie = variant === "pie"; // A pie is a ring with no hole: it reaches in to the centre. const innerRadius = pie ? 0 : DONUT_INNER_RADIUS; const ring = ringOf(innerRadius); // The palette is spread over the shares that are filled: an empty share is // neutral and takes no place in it. The last share and the first meet where // the ring closes, so they never share a colour. const colors = chartRingColors( palette, data.filter((item) => !item.empty).length, ); let series = 0; const rows = data.map((item) => { const index = item.empty ? -1 : series++; return { ...item, value: Number.isFinite(item.value) ? Math.max(0, item.value) : 0, color: item.color ?? (item.empty ? chartEmptyColor : colors[index]!), }; }); const total = rows.reduce((sum, row) => sum + row.value, 0); // The ring as it is drawn now, in percent of the circle: when the data // changes, each share glides to its new size and place, a new share grows // from nothing and a share that is gone shrinks away. At rest this is the // data itself. const arcs = useTweenedRows( rows, (row) => (total > 0 ? (row.value / total) * 100 : 0), { container: chart }, ); let offset = 0; const drawn = arcs.map((arc) => { const start = offset; offset += arc.value; return { ...arc, start }; }); // The part of the circle that no share fills, which the track shows: all of // it in a chart with nothing in it, the gap while shares grow from nothing or // shrink to it, and none at rest, where the shares fill the circle. const filled = Math.min(100, Math.max(0, offset)); const unfilled = 100 - filled; // The share under a point, from its angle within the ring or the disc. One // lookup for the whole chart keeps the active share steady while the pointer // crosses from one share to the next. const segmentAt = (clientX: number, clientY: number): string | null => { const box = chart.current?.getBoundingClientRect(); if (!box || !box.width || total <= 0) return null; const scale = PIE_BOX / box.width; return shareAtPoint( drawn, (clientX - box.x - box.width / 2) * scale, (clientY - box.y - box.height / 2) * scale, innerRadius, ); }; // The chart core keeps the active share, the pointer, the tap that pins, the // arrow keys and the tooltip; the ring only says which share is under a point. // A ring with nothing in it has no share to show: no tooltip, no focus stop. const interaction = useChartInteraction({ keys: total > 0 ? rows.map((row) => row.id) : [], itemAt: segmentAt, wrap: true, }); const selected = interaction.activeKey === null ? undefined : rows.find((row) => row.id === interaction.activeKey); // The share whose entry the mouse is over in the legend. The chart owns it, // as it owns the active share: one state, handed to the legend and the ring. // The legend sets it only in a chart that pops out, where it has something to // show. const focus = useChartSeriesFocus(rows.map((row) => row.id)); // One share is highlighted, which steps out with `popOut`: the share under // the pointer, the one a tap or the keyboard has made active, or, in a chart // that pops out, the one whose entry the mouse is over in the legend. The // mouse on the legend wins, and the tooltip stays with the plot's own share. // Nothing fades in a Pie Chart. const highlightedKey = (popOut ? focus.focusedKey : null) ?? interaction.activeKey; // How far the highlighted share steps out, in the units of the box: none // unless the chart pops out, so nothing moves, whatever highlights a share. const popUnits = popOut ? popDistance(size) : 0; // The card sits beside the middle of the share, where it stands: stepped out // while it is the highlighted one and the chart pops out. const anchorFor = (key: string) => { const arc = drawn.find((item) => item.key === key); const box = chart.current?.getBoundingClientRect(); if (!arc || total <= 0 || !box) return null; const point = shareAnchor( arc.start, arc.value, innerRadius, key === highlightedKey ? popOffset(arc.start, arc.value, popUnits) : undefined, ); return { x: box.x + box.width * point.x, y: box.y + box.height * point.y, }; }; // What the chart says about its shares to assistive technology when it takes // focus: its empty label when there is nothing in the ring, and without a // legend each share and its value, which the legend reads out otherwise. const description = total <= 0 ? emptyLabel : legend ? undefined : // A full stop ends it, so that the keyboard hint after it is heard // as a sentence of its own. `${rows.map((row) => `${row.label}: ${formatValue(row.value)}`).join("; ")}.`; return ( // The figure holds the ring and the legend. The focus stop inside is the // chart for assistive technology, named by the label alone.
{selected.label}
) } announcement={ selected ? chartTooltipText(undefined, [ { label: selected.label, value: formatValue(selected.value) }, ]) : undefined } description={description} describedBy={describedBy} keyboardHint={words.keyboardHint} roleDescription={words.roleDescription} className="relative max-w-full rounded-full" style={{ width: size, height: size }} > {/* The ring keeps its size at rest and the box is as tight as it was: a share that steps out is drawn outside it, so a chart that pops out must not have its SVG clip it. It is a picture that the focus stop describes, so it is hidden from assistive technology. */} {/* The empty label is the charts' own empty state, in HTML over the ring, so that it keeps its 14 px at any size of the chart. */} {total <= 0 && } {/* The centre is HTML over the plot, in the hole, which is half the width of the chart: the square around the hole, cut off at its round edge, so that nothing in it covers the ring. It sits inside the focus stop, whose name is the label alone, and never takes the pointer, so the lookup of the ring sees every point of the hole. A chart with nothing to draw shows its empty label there instead. */} {!pie && total > 0 && (center != null || (centerValue !== undefined && Number.isFinite(centerValue))) && (
{center ?? ( )}
)}
{legend && ( ({ key: row.id, label: row.label, color: row.color, value: formatValue(row.value), }))} /> )}
); } // ~/src/siteplane/components/pie-chart/pie-geometry.ts /* * The geometry of the Pie Chart. It is drawn in a 200 x 200 box around its * centre. A share is a number of percent of the circle, counted from 12 o'clock * clockwise, so the ring, the wedges, the pointer and the tooltip all agree on * where a share is. * * The way the active share steps out, along its middle angle by a fixed * distance, is adapted from bklit UI's pie-slice.tsx (`getSliceOffset`) and * pie-chart.tsx (`DEFAULT_HOVER_OFFSET`), MIT. Everything else is Siteplane's * own. * * MIT License * * Copyright (c) 2026 uixmat * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { estimateLabelWidth } from "../chart/chart-scales"; /** The side of the box the chart is drawn in: the view box of its SVG. */ export const PIE_BOX = 200; const CENTER = PIE_BOX / 2; /** The radius of the rim. The ring and the pie both reach the edge of the box. */ export const PIE_OUTER_RADIUS = 100; /** The radius of the hole of a donut. A pie has none. */ export const DONUT_INNER_RADIUS = 50; /** * The centre line and the width of the stroke that draws a ring from the inner * radius out to the rim. With no inner radius the stroke fills the disc. */ export function ringOf(innerRadius: number): { radius: number; width: number } { return { radius: (PIE_OUTER_RADIUS + innerRadius) / 2, width: PIE_OUTER_RADIUS - innerRadius, }; } /** * The path that a share of the ring is dashed along: the circle of `radius`, * from 12 o'clock clockwise and back, left open. A closed circle would let the * dasher join the last dash to the first at the closing point, and a share that * ends exactly there then showed a stray wedge for some data. */ export function ringPath(radius: number): string { const top = CENTER - radius; const bottom = CENTER + radius; return `M ${CENTER} ${top} A ${radius} ${radius} 0 1 1 ${CENTER} ${bottom} A ${radius} ${radius} 0 1 1 ${CENTER} ${top}`; } /** A coordinate as short as it needs to be: three decimals, and none that are 0. */ function coordinate(value: number): number { return Math.round(value * 1000) / 1000; } /** The point on the rim `percent` of the way round, as the two numbers of a path. */ function rimPoint(percent: number): string { const angle = (percent / 100) * 2 * Math.PI; const x = CENTER + PIE_OUTER_RADIUS * Math.sin(angle); const y = CENTER - PIE_OUTER_RADIUS * Math.cos(angle); return `${coordinate(x)} ${coordinate(y)}`; } // A share this close to the whole is drawn as the disc. A wedge that ends where // it began has an arc of no length, which a browser leaves out: the share would // vanish instead of filling the circle. const WHOLE = 99.999; /** * The path of the wedge that fills the disc from `start` to `start + value` * percent of the circle: from the centre out to the rim, along the rim, and * back. An empty string when the share has nothing in it. */ export function wedgePath(start: number, value: number): string { if (!(value > 0) || !Number.isFinite(start)) return ""; const radius = PIE_OUTER_RADIUS; if (value >= WHOLE) { return `M ${CENTER} 0 A ${radius} ${radius} 0 1 1 ${CENTER} ${PIE_BOX} A ${radius} ${radius} 0 1 1 ${CENTER} 0 Z`; } const longWay = value > 50 ? 1 : 0; return `M ${CENTER} ${CENTER} L ${rimPoint(start)} A ${radius} ${radius} 0 ${longWay} 1 ${rimPoint(start + value)} Z`; } /** A share as it is drawn now: where it starts and how much of the circle it holds. */ export type PieArc = { key: string; start: number; value: number; /** A share that has left the data and is shrinking away. */ leaving: boolean; }; /** * The share under a point, found by the angle of the point within the ring, or * the disc when `innerRadius` is 0. The point is an offset from the centre in * the box, with y growing downwards. Null outside the rim and in a hole. One * lookup for the whole chart keeps the active share steady while the pointer * crosses from one share to the next; it reads the shares as they are drawn, so * the share under the pointer is the one seen. */ export function shareAtPoint( arcs: readonly PieArc[], dx: number, dy: number, innerRadius: number, ): string | null { const distance = Math.hypot(dx, dy); if (distance < innerRadius || distance > PIE_OUTER_RADIUS) return null; const turn = ((Math.atan2(dx, -dy) / (2 * Math.PI) + 1) % 1) * 100; for (const arc of arcs) { if ( !arc.leaving && arc.value > 0 && turn >= arc.start && turn < arc.start + arc.value ) return arc.key; } return null; } /** * The direction a share points in: the angle of the middle of its arc, in * radians from 12 o'clock, clockwise. */ export function middleAngle(start: number, value: number): number { return ((start + value / 2) / 100) * 2 * Math.PI; } /** * Where the tooltip sits for a share from the keyboard or a tap, as fractions * of the box: the middle of its arc, halfway between the inner radius and the * rim. `out` is how far the share has stepped out (`popOffset`, in the units of * the box): the tooltip sits beside the share where it stands. */ export function shareAnchor( start: number, value: number, innerRadius: number, out: { x: number; y: number } = { x: 0, y: 0 }, ): { x: number; y: number } { // From 12 o'clock, as `middleAngle` counts, to the 3 o'clock of the circle's own maths. const angle = middleAngle(start, value) - Math.PI / 2; const reach = (PIE_OUTER_RADIUS + innerRadius) / 2 / PIE_BOX; return { x: 0.5 + reach * Math.cos(angle) + out.x / PIE_BOX, y: 0.5 + reach * Math.sin(angle) + out.y / PIE_BOX, }; } /** * The text steps of a figure in the hole of a donut, in px, from the largest: * the 30 px of a Metric Card's figure down to the 12 px of a caption. */ export const PIE_CENTER_STEPS = [30, 24, 20, 16, 14, 12] as const; export type PieCenterStep = (typeof PIE_CENTER_STEPS)[number]; // The steps in which a figure keeps all its digits; below them it is written // compact first, so a large total reads as 1.2M in a large step rather than // in full in a small one. const fullSteps = 3; // The caption under a figure: 12 px on a line of its height, 4 px below it. const captionSize = 12; const captionGap = 4; /** Where a figure and its caption sit in the hole: the step of the figure, whether it is compact, and how wide the caption may be. */ export type PieCenterFit = { compact: boolean; size: PieCenterStep; /** The widest the caption may be, in percent of the hole's width: the chord of the hole at the caption's lower edge. */ captionWidth: number; }; /** * The step a figure in the hole of a donut `chartSize` px across takes, and * whether it is written compact: the first that fits, in the order of * `PIE_CENTER_STEPS`, the full figure in the three largest steps and then the * compact one in every step. A line fits when its corners stay inside the hole, * less a margin of 12 % of its radius from the ring (at least 4 px), with the * figure and the caption centred in the hole as one block. The widths are the * kit's estimate (`estimateLabelWidth`), so that the server and the client * pick the same step; it errs on the wide side. A figure that fits in no step * takes the smallest, compact. */ export function pieCenterFit({ caption, chartSize, compact, full, }: { caption?: string | undefined; chartSize: number; /** The figure in compact notation. */ compact: string; /** The figure in full. */ full: string; }): PieCenterFit { const hole = (Math.max(0, chartSize) * DONUT_INNER_RADIUS) / PIE_BOX; const room = Math.max(0, hole - Math.max(4, hole * 0.12)); // The width of a line whose corners sit `edge` px above or below the centre. const chord = (edge: number) => edge >= room ? 0 : 2 * Math.sqrt(room * room - edge * edge); const blockHeight = (size: number) => size + (caption ? captionGap + captionSize : 0); const width = (text: string, size: number) => (estimateLabelWidth(text) * size) / 12; const candidates = [ ...PIE_CENTER_STEPS.slice(0, fullSteps).map((size) => ({ compact: false, size, })), ...PIE_CENTER_STEPS.map((size) => ({ compact: true, size })), ]; const chosen = candidates.find( ({ compact: short, size }) => width(short ? compact : full, size) <= chord(blockHeight(size) / 2), ) ?? candidates[candidates.length - 1]!; const captionWidth = hole > 0 ? (chord(blockHeight(chosen.size) / 2) / (2 * hole)) * 100 : 0; return { captionWidth: Math.round(captionWidth * 100) / 100, compact: chosen.compact, size: chosen.size, }; } /** How far the active share steps out of the ring, in px: bklit UI's `hoverOffset`. */ export const PIE_POP_OUT = 10; /** * How far that is in the units of the box, for a chart drawn `size` px across: * the box is scaled to the size, so the step stays `PIE_POP_OUT` px whatever * the size of the chart is. */ export function popDistance(size: number): number { return (PIE_POP_OUT * PIE_BOX) / Math.max(1, size); } /** * Where the active share moves to: `distance` along its middle angle, away from * the centre, as a translate in the units of `distance`, with y growing * downwards (bklit's `getSliceOffset`: x is the sine of the angle and y minus * its cosine, from 12 o'clock). A share that is the whole has nowhere outside * itself to go and stays. */ export function popOffset( start: number, value: number, distance: number, ): { x: number; y: number } { if (!(value > 0) || value >= WHOLE) return { x: 0, y: 0 }; const angle = middleAngle(start, value); // `|| 0` keeps a minus sign off a zero that the rounding leaves. return { x: coordinate(Math.sin(angle) * distance) || 0, y: coordinate(-Math.cos(angle) * distance) || 0, }; } // ~/src/siteplane/components/pie-chart/index.ts export * from "./pie-chart";