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.
This commit is contained in:
2026-08-24 18:40:17 +02:00
parent 08d756cc87
commit ad26aca198
29 changed files with 2940 additions and 886 deletions
@@ -1,25 +1,32 @@
import { render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import type { StatsTimeseries } from "@/lib/types";
import { formatTime } from "@/lib/format";
import type { Bucket, StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import TimeseriesChart from "./TimeseriesChart";
const SINCE = 1_700_000_000;
function timeseries(bucketCount: number): StatsTimeseries {
function timeseries(buckets: Bucket[]): StatsTimeseries {
return {
period: "24h",
since: SINCE,
until: SINCE + bucketCount * 1800,
until: SINCE + buckets.length * 1800,
bucket_seconds: 1800,
coverage: { complete: true, available_since: SINCE },
buckets: Array.from({ length: bucketCount }, (_, i) => ({
buckets,
};
}
function counting(bucketCount: number): StatsTimeseries {
return timeseries(
Array.from({ length: bucketCount }, (_, i) => ({
ts: SINCE + i * 1800,
queries: i + 1,
blocked: 1,
cached: 1,
})),
};
);
}
/** The element wearing the shared hidden style, found by its compiled classes. */
@@ -29,8 +36,13 @@ function hiddenElement(container: HTMLElement): Element | null {
return container.querySelector(classes.map((name) => `.${name}`).join(""));
}
/** The transparent per-bucket hit targets, in bucket order. */
function overlayRects(container: HTMLElement): SVGRectElement[] {
return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
}
test("the data table is the SVG's accessible equivalent", () => {
render(<TimeseriesChart data={timeseries(3)} />);
render(<TimeseriesChart data={counting(3)} />);
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
const table = screen.getByRole("table", { name: "Queries per time bucket" });
@@ -44,9 +56,339 @@ test("the data table is the SVG's accessible equivalent", () => {
* push the document's scroll height a screen past the app shell.
*/
test("the hidden data table is clipped by a block wrapper, not by the table itself", () => {
const { container } = render(<TimeseriesChart data={timeseries(48)} />);
const { container } = render(<TimeseriesChart data={counting(48)} />);
const hidden = hiddenElement(container);
expect(hidden?.tagName).toBe("DIV");
expect(hidden?.querySelector("table")).not.toBeNull();
});
/**
* "Allowed" is what the reported total leaves over, and the three counts come
* from separate columns that a partial write can leave inconsistent. A negative
* remainder would draw a segment upside down.
*/
test("allowed is the remainder of the reported total, clamped at zero", () => {
const { container } = render(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 8, cached: 5 }])} />,
);
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
expect(
within(row)
.getAllByRole("cell")
.map((cell) => cell.textContent),
).toEqual(["10", "8", "5", "0"]);
// Blocked and cached are drawn; the empty "allowed" segment is not.
expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0);
// The scale comes from the reported total, not from the stack's own sum.
// Scaling to the sum would reach 13 here and leave the bar four fifths of the
// way up a plot whose own numbers say it is full.
const ticks = Array.from(container.querySelectorAll(".visx-axis-left text")).map((tick) => tick.textContent);
expect(ticks[ticks.length - 1]).toBe("10");
});
test("a window with no queries says so instead of drawing an empty grid", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
{ ts: SINCE + 1800, queries: 0, blocked: 0, cached: 0 },
])}
/>,
);
expect(screen.getByText("No queries in this period.")).toBeTruthy();
expect(container.querySelector("svg")).toBeNull();
});
test("no bucket at all says the same thing", () => {
render(<TimeseriesChart data={timeseries([])} />);
expect(screen.getByText("No queries in this period.")).toBeTruthy();
});
/**
* The three category colours are fixed constants of this chart rather than
* anything derived from `seriesColors`, which would paint "other" grey.
*/
test("the segments are drawn in this chart's own category colours", () => {
const { container } = render(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
);
const fills = Array.from(container.querySelectorAll("rect"))
.map((rect) => rect.getAttribute("fill"))
.filter((fill) => fill !== "transparent");
expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]);
});
/**
* The graphic carries no `<title>`: it duplicated the tooltip, the browser
* showed it after its own delay and out of the app's styling, and the hidden
* table is already the accessible equivalent.
*/
test("hover text is the tooltip alone, never a bare SVG title", () => {
const { container } = render(<TimeseriesChart data={counting(3)} />);
expect(container.querySelectorAll("title")).toHaveLength(0);
});
/** The hit target is the whole column slot, including the space above a short stack. */
test("each bucket's hit target spans the full plot height", () => {
const { container } = render(<TimeseriesChart data={counting(3)} />);
const rects = overlayRects(container);
expect(rects).toHaveLength(3);
for (const rect of rects) {
expect(rect.getAttribute("y")).toBe("8");
expect(rect.getAttribute("height")).toBe("210");
}
});
/**
* A band scale spends a gap after the last column as well as between them, so a
* full-step hit target on the last bucket would reach into the right margin and
* catch pointers that are past the plot entirely.
*/
test("the last hit target stops at the plot's right edge", () => {
const { container } = render(<TimeseriesChart data={counting(3)} />);
const last = overlayRects(container).at(-1) as SVGRectElement;
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
// 640 fallback width, less the 44px left and 8px right margins.
expect(right).toBeCloseTo(44 + 588, 6);
});
test("pointing at a bucket names its total and every series, and dims the rest", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
])}
/>,
);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
expect(values).toEqual(["100", "40", "10", "50"]);
const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
// One swatch per series, in the colour the segment is drawn in.
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
expect(swatches[0]).toContain("#ef4444");
expect(swatches[1]).toContain("#059669");
expect(swatches[2]).toContain("#3b82f6");
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
});
/**
* The tooltip sits 8px down from the chart's top edge and 8px to the side of the
* slot it names — the placement the hand-rolled tooltip had, restored over
* visx's own 10px defaults.
*/
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
const { container } = render(<TimeseriesChart data={counting(2)} />);
fireEvent.mouseOver(overlayRects(container)[0]);
// The first slot spans 44 to 44 + step, so its centre is 191.5 and the
// tooltip sits 8px right of it. visx rounds the placement to whole pixels.
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
});
/**
* `withBoundingRects` measures its node once, on mount. Sharing one mount across
* buckets would place a wide bucket's tooltip with a narrow bucket's measured
* width, which flips or clips it at the right-hand edge of the plot. Remounting
* per bucket is what forces a fresh measurement; jsdom reports every rect as
* zero, so the mount is what this can observe, not the measurement itself.
*/
test("each bucket gets its own tooltip mount, so each is measured for itself", () => {
const { container } = render(<TimeseriesChart data={counting(2)} />);
const rects = overlayRects(container);
fireEvent.mouseOver(rects[0]);
const first = container.querySelector(".visx-tooltip");
fireEvent.mouseOver(rects[1]);
const second = container.querySelector(".visx-tooltip");
expect(first).not.toBeNull();
expect(second).not.toBe(first);
});
/**
* The window refreshes every half minute under an open tooltip. The tooltip
* holds a bucket index and reads the numbers out of the render it is drawing, so
* a refresh that keeps the same buckets updates it rather than leaving last
* minute's counts on screen.
*/
test("a refresh in the same window retells the hovered bucket with the new counts", () => {
const before = timeseries([
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
]);
const { container, rerender } = render(<TimeseriesChart data={before} />);
fireEvent.mouseOver(overlayRects(container)[0]);
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
).toEqual(["100", "40", "10", "50"]);
rerender(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 120, blocked: 60, cached: 10 },
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
])}
/>,
);
const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map(
(dd) => dd.textContent,
);
expect(values).toEqual(["120", "60", "10", "50"]);
});
/**
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
* was over is gone, so index 0 now names a different span. The tooltip goes away
* rather than describing a bucket that is no longer drawn, and nothing stays
* dimmed behind it. Rolling back to the earlier window must not bring it back
* either: the selection is deleted when the window moves, not held aside.
*/
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
const { container, rerender } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
])}
/>,
);
fireEvent.mouseOver(overlayRects(container)[0]);
expect(container.querySelectorAll("dl")).toHaveLength(1);
rerender(
<TimeseriesChart
data={{
...timeseries([
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
{ ts: SINCE + 3600, queries: 70, blocked: 7, cached: 7 },
]),
since: SINCE + 1800,
}}
/>,
);
expect(container.querySelectorAll("dl")).toHaveLength(0);
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
rerender(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
])}
/>,
);
expect(container.querySelectorAll("dl")).toHaveLength(0);
});
/**
* The same bucket can change width between refreshes — a count crossing a digit
* boundary, a client name arriving — and a mount measured at the old width is
* placed at the wrong one. A content change therefore remounts the tooltip, the
* same way moving between buckets does.
*/
test("a bucket whose numbers change is remounted, so it is measured again", () => {
const { container, rerender } = render(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 9, blocked: 4, cached: 1 }])} />,
);
fireEvent.mouseOver(overlayRects(container)[0]);
const first = container.querySelector(".visx-tooltip");
rerender(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 1000, blocked: 4, cached: 1 }])} />);
const second = container.querySelector(".visx-tooltip");
expect(first).not.toBeNull();
expect(second).not.toBeNull();
expect(second).not.toBe(first);
});
test("leaving the chart takes the tooltip and the dimming with it", () => {
const { container } = render(<TimeseriesChart data={counting(3)} />);
fireEvent.mouseOver(overlayRects(container)[0]);
expect(container.querySelectorAll("dl")).toHaveLength(1);
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
expect(container.querySelectorAll("dl")).toHaveLength(0);
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
});
/**
* The third series is every query neither blocked nor served from cache. It was
* called "Other", which named the arithmetic rather than the thing.
*/
test("the remainder series is called Allowed everywhere it surfaces", () => {
const { container } = render(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 2, cached: 3 }])} />,
);
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
expect(
within(screen.getByRole("table"))
.getAllByRole("columnheader")
.map((cell) => cell.textContent),
).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]);
fireEvent.mouseOver(overlayRects(container)[0]);
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
expect(screen.queryByText("Other")).toBeNull();
});
/**
* A window where everything was blocked or served from cache has no allowed
* queries, and that is worth reading rather than hiding: an absent series would
* say the same thing as a series nobody looked at. The three categories are all
* real answers a query can get, so none of them is dropped for counting zero.
* The client chart's "Other" is dropped at zero, but that one aggregates clients
* beyond the top eight rather than naming a kind of answer.
*/
test("a window with nothing allowed keeps the series at zero", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 4, blocked: 4, cached: 0 },
{ ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 },
])}
/>,
);
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([
"Queries",
"Blocked",
"Cached",
"Allowed",
]);
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]);
});