Files
nxdns/admin/src/features/overview/Donut.tsx
T
mokhtar 728b8d64e1 admin: draw the overview charts with visx
the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0
primitives; rendering, colours and themes stay the app's own. all four charts
share one hover treatment: the client chart gains the tooltip and dimming the
query timeline had, the donuts gain both, an open tooltip follows a data
refresh instead of going stale, and it retires when the window rolls. the
timeline's third series is named allowed instead of other, and the client
chart's other aggregate disappears from a window where it counted nothing.
licenses gain the isc text for the bundled d3 modules.
2026-08-24 18:40:17 +02:00

295 lines
9.4 KiB
TypeScript

/**
* 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 <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
}
return (
<div {...stylex.props(styles.body)}>
<div {...stylex.props(styles.ring)}>
{/* 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. */}
<svg
aria-hidden="true"
focusable="false"
width={SIZE}
height={SIZE}
viewBox={`0 0 ${SIZE} ${SIZE}`}
onMouseLeave={hovered.clear}
>
{/* Arc paths are generated around the origin, and `Pie`'s own
`top`/`left` group is skipped when it is given a render prop, so
the ring is centred here instead. */}
<Group top={OUTER_RADIUS} left={OUTER_RADIUS}>
<Pie
data={drawn}
pieValue={(slice) => slice.value}
// The caller has already ranked the slices, so d3's own sort is off
// in both of its forms and the ring runs clockwise from twelve
// o'clock in the order given. @visx/shape 4.0.0 already disables
// it by default — but that default exists because d3-shape v3
// changed sortValues to descending under it, so saying it here is
// what keeps a future bump from silently re-ranking the ring.
pieSort={null}
pieSortValues={null}
outerRadius={OUTER_RADIUS}
innerRadius={INNER_RADIUS}
>
{({ arcs, path }) =>
arcs.map((arc, index) => (
// The stroke is what keeps a shared hue from lying. Colour is a
// pure function of identity, so two neighbouring slices can come
// out the same; outlined in the panel's own colour they still
// read as two shapes rather than merging into one. Attributes
// rather than a class, as the client chart's segments are, so
// the separation is visible to a test and not only to a
// stylesheet.
<path
key={arc.data.key}
d={path(arc) ?? ""}
fill={arc.data.color}
stroke={colors.surfaceRaised}
strokeWidth={1}
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
onMouseEnter={() => hovered.show(index)}
/>
))
}
</Pie>
</Group>
</svg>
{hovered.index !== null && (
<ChartTooltip
index={hovered.index}
content={tooltipOf(hovered.index)}
{...sliceAnchor(drawn, hovered.index, total)}
/>
)}
</div>
<ul {...stylex.props(styles.legend)}>
{drawn.map((slice) => (
<li key={slice.key} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(slice.color))} />
<span {...stylex.props(styles.label)}>
{slice.label}
{slice.secondary !== undefined && (
<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
)}
</span>
<span {...stylex.props(styles.count, shared.tabularNums)}>
{numberFormat.format(slice.value)}
</span>
<span {...stylex.props(styles.share, shared.tabularNums)}>
{sharePercent(slice.value / total)}
</span>
</li>
))}
</ul>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>{caption}</caption>
<thead>
<tr>
<th scope="col">Entry</th>
<th scope="col">{unit}</th>
<th scope="col">Share</th>
</tr>
</thead>
<tbody>
{drawn.map((slice) => (
<tr key={slice.key}>
<th scope="row">
{slice.secondary === undefined
? slice.label
: `${slice.label} (${slice.secondary})`}
</th>
<td>{slice.value}</td>
<td>{sharePercent(slice.value / total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}