/** * 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 { Group } from "@visx/group"; import { Pie } from "@visx/shape"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit"; export interface DonutSlice { /** Semantic identity: the React key, the colour key and the legend's identity. */ key: string; label: string; /** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */ secondary?: string; value: number; color: string; } const SIZE = 180; const THICKNESS = 36; const OUTER_RADIUS = SIZE / 2; const INNER_RADIUS = OUTER_RADIUS - THICKNESS; 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", }, /** The tooltip is placed against the ring's own box, so slice coordinates can * be used unchanged rather than measured against the whole panel. */ ring: { position: "relative", flexShrink: 0, lineHeight: 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)}%`; } /** * Where a slice's tooltip points: the middle of its arc, in the ring box's own * coordinates. This restates the `Pie` configuration below — clockwise from * twelve o'clock, caller order, no pad angle — and the two must agree. */ function sliceAnchor(drawn: DonutSlice[], index: number, total: number): { left: number; top: number } { const before = drawn.slice(0, index).reduce((sum, slice) => sum + slice.value, 0); const middle = (2 * Math.PI * (before + drawn[index].value / 2)) / total; const radius = (OUTER_RADIUS + INNER_RADIUS) / 2; return { left: OUTER_RADIUS + Math.sin(middle) * radius, top: OUTER_RADIUS - Math.cos(middle) * radius, }; } 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; }) { // Zero-valued entries have no arc to draw and a legend entry reading 0 is // noise, so they are dropped before anything is measured or drawn. const drawn = slices.filter((slice) => slice.value > 0); const total = drawn.reduce((sum, slice) => sum + slice.value, 0); // A hover names a slice by position, so it survives only while the ring is // still made of the same slices in the same order. const hovered = useActiveIndex(drawn.map((slice) => slice.key).join(",")); function tooltipOf(index: number): TooltipContent { const slice = drawn[index]; return { title: slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`, rows: [ { key: "value", label: unit, color: slice.color, value: numberFormat.format(slice.value) }, { key: "share", label: "Share", value: sharePercent(slice.value / total) }, ], }; } if (total === 0) { return
No queries in this period.
; } return (
{/* The ring stays out of the accessibility tree even though it is now a pointer target: the tooltip repeats what the legend beside it already says in text, so nothing here is the only copy. */} {hovered.index !== null && ( )}
{drawn.map((slice) => ( ))}
{caption}
Entry {unit} Share
{slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`} {slice.value} {sharePercent(slice.value / total)}
); }