Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
210 lines
5.8 KiB
TypeScript
210 lines
5.8 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 { 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 <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
|
|
}
|
|
|
|
return (
|
|
<div {...stylex.props(styles.body)}>
|
|
<svg
|
|
aria-hidden="true"
|
|
focusable="false"
|
|
width={SIZE}
|
|
height={SIZE}
|
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
|
{...stylex.props(styles.ring)}
|
|
>
|
|
{layout.arcs.map((arc) => (
|
|
// 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.slice.key}
|
|
d={arc.d}
|
|
fill={arc.slice.color}
|
|
fillRule="evenodd"
|
|
stroke={colors.surfaceRaised}
|
|
strokeWidth={1}
|
|
/>
|
|
))}
|
|
</svg>
|
|
<ul {...stylex.props(styles.legend)}>
|
|
{layout.arcs.map((arc) => (
|
|
<li key={arc.slice.key} {...stylex.props(styles.legendItem)}>
|
|
<span
|
|
aria-hidden="true"
|
|
{...stylex.props(styles.swatch, styles.swatchColor(arc.slice.color))}
|
|
/>
|
|
<span {...stylex.props(styles.label)}>
|
|
{arc.slice.label}
|
|
{arc.slice.secondary !== undefined && (
|
|
<span {...stylex.props(styles.secondary)}>{arc.slice.secondary}</span>
|
|
)}
|
|
</span>
|
|
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
|
{numberFormat.format(arc.slice.value)}
|
|
</span>
|
|
<span {...stylex.props(styles.share, shared.tabularNums)}>{sharePercent(arc.share)}</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>
|
|
{layout.arcs.map((arc) => (
|
|
<tr key={arc.slice.key}>
|
|
<th scope="row">
|
|
{arc.slice.secondary === undefined
|
|
? arc.slice.label
|
|
: `${arc.slice.label} (${arc.slice.secondary})`}
|
|
</th>
|
|
<td>{arc.slice.value}</td>
|
|
<td>{sharePercent(arc.share)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|