milestone 30: overview as a dashboard, explicit health contract, period aggregations
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
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
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user