/** * A breakdown as a ring, a legend and a table. * * The ring is decoration: it carries `aria-hidden` and `focusable="false"`, * because a non-focusable SVG is still in the accessibility tree and would * announce a pile of unlabelled paths. Everything the ring says is said again in * the legend — visibly, with the share and the count — and once more in a * visually hidden table, which is the surface a screen reader reads. * * Labels can collide: two rows can both be "Unknown", and one upstream name can * appear under two route kinds. Identity is therefore the caller's `key`, and an * entry that needs disambiguating carries `secondary` text saying which it is. */ import * as stylex from "@stylexjs/stylex"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import { layoutDonut, type DonutSlice } from "./donutLayout"; const SIZE = 180; const THICKNESS = 36; const numberFormat = new Intl.NumberFormat(); /** The width at which the page puts the two donuts side by side, and the page's * own grid switches on the same query. StyleX will not take it from an import, * so it is written out in both modules and must be changed in both. */ const TWO_COLUMN = "@media (min-width: 1280px)"; const styles = stylex.create({ empty: { display: "flex", alignItems: "center", justifyContent: "center", minHeight: SIZE, borderRadius: "0.25rem", borderWidth: 1, borderStyle: "dashed", borderColor: colors.borderStrong, fontSize: "0.875rem", lineHeight: "1.25rem", color: colors.textMuted, }, /** * Centred while the panels are stacked, left-anchored once they are side by * side. Stacked, the panel is as wide as the page and a ring pinned to the * left edge reads as a mistake; in a column it is one of a pair and lines up * with everything above it. */ body: { display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" }, gap: "1.25rem", }, ring: { flexShrink: 0, }, /** * Capped and left-anchored. Without the cap the row justifies across whatever * the panel is given — most of a metre of whitespace on a wide monitor — and a * label stops reading as belonging to the count opposite it. */ legend: { flex: 1, minWidth: "12rem", maxWidth: "24rem", display: "flex", flexDirection: "column", gap: "0.25rem", listStyleType: "none", padding: 0, margin: 0, fontSize: "0.875rem", lineHeight: "1.25rem", }, legendItem: { display: "flex", alignItems: "baseline", gap: "0.5rem", }, swatch: { flexShrink: 0, alignSelf: "center", display: "inline-block", width: "0.625rem", height: "0.625rem", borderRadius: "0.125rem", }, /** Dynamic: the swatch takes the colour the ring is drawn in. */ swatchColor: (color: string) => ({ backgroundColor: color }), label: { flex: 1, minWidth: 0, overflowWrap: "anywhere", }, secondary: { marginLeft: "0.375rem", fontSize: "0.75rem", lineHeight: "1rem", color: colors.textMuted, }, count: { color: colors.textSecondary, }, share: { minWidth: "3rem", textAlign: "right", color: colors.textMuted, }, }); function sharePercent(share: number): string { return `${(share * 100).toFixed(1)}%`; } export default function Donut({ slices, caption, unit, }: { slices: DonutSlice[]; /** Names the hidden table, so a screen reader knows which breakdown it is in. */ caption: string; /** The column header for the counted thing, e.g. "Queries". */ unit: string; }) { const layout = layoutDonut(slices, SIZE, THICKNESS); if (layout.total === 0) { return
| Entry | {unit} | Share |
|---|---|---|
| {arc.slice.secondary === undefined ? arc.slice.label : `${arc.slice.label} (${arc.slice.secondary})`} | {arc.slice.value} | {sharePercent(arc.share)} |