Files
nxdns/admin/src/features/overview/TimeseriesChart.tsx
T

255 lines
7.4 KiB
TypeScript

/**
* Query volume over the window as a smoothed area: every query in blue, the
* blocked share in red along the bottom (ui-visual-redesign.md). Pointing at
* the plot selects the nearest bucket and marks it with a guide line and a dot
* on each curve; the tooltip sits beside the point so it never covers the slot
* the reader is looking at.
*/
import { useId } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatCount } from "@/lib/format";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import {
CHART_HEIGHT,
ChartFrame,
ChartLegend,
ChartRoot,
ChartTooltip,
EmptyChart,
HitBands,
STILL_COUNTING_DASH,
STILL_COUNTING_OPACITY,
areaPath,
bucketTitle,
labelTickValues,
plotArea,
pointScale,
smoothPath,
svgId,
useActiveIndex,
useMeasuredWidth,
valueScale,
valueTicks,
type TooltipContent,
} from "./chartKit";
import { CHART_BLUE, CHART_RED } from "./seriesColors";
/**
* Drawn bottom-up in this order: the total's fill is faint so the blocked
* curve, painted over it, stays a solid band along the baseline.
*/
const SERIES = [
{ key: "queries", label: "Total queries", color: CHART_BLUE, fillOpacity: 0.13 },
{ key: "blocked", label: "Blocked", color: CHART_RED, fillOpacity: 0.35 },
] as const;
type SeriesKey = (typeof SERIES)[number]["key"];
const LINE_WIDTH = 2;
const DOT_RADIUS = 4;
const styles = stylex.create({
guide: {
stroke: colors.borderStrong,
strokeWidth: 1,
},
/** The dot's ring is the panel's own surface, so it stays legible over either fill. */
dot: {
stroke: colors.surfaceRaised,
strokeWidth: 2,
},
});
interface Column {
ts: number;
queries: number;
blocked: number;
/** queries - blocked, clamped at 0. */
allowed: number;
}
function columnsOf(buckets: Bucket[]): Column[] {
return buckets.map((bucket) => ({
ts: bucket.ts,
queries: bucket.queries,
blocked: bucket.blocked,
allowed: Math.max(0, bucket.queries - bucket.blocked),
}));
}
function tooltipOf(column: Column, stillCounting: boolean): TooltipContent {
return {
title: bucketTitle(column.ts, stillCounting),
rows: [
...SERIES.map((series) => ({
key: series.key,
label: series.label,
color: series.color,
value: formatCount(column[series.key]),
})),
{ key: "allowed", label: "Allowed", value: formatCount(column.allowed) },
],
};
}
/**
* The slice of the Overview body this chart draws. Declared here rather than
* taken whole, so what the chart reads is stated where it is read.
*/
export interface TimeseriesData {
since: number;
bucket_seconds: number;
buckets: Bucket[];
}
export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
const [containerRef, width] = useMeasuredWidth();
// A hover survives a re-render only while it still names the same bucket at
// the same place: a poll that rolls the window, or a resize, retires it.
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.buckets.length}:${width}`);
const clipId = svgId(useId());
if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) {
return <EmptyChart containerRef={containerRef} />;
}
const columns = columnsOf(data.buckets);
const timestamps = columns.map((column) => column.ts);
const peak = Math.max(...columns.map((column) => column.queries));
const plot = plotArea(width, peak);
const xScale = pointScale(timestamps, plot);
const yScale = valueScale(peak, [plot.bottom, plot.y]);
const yTicks = valueTicks(yScale);
const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
const pointsOf = (key: SeriesKey): [number, number][] =>
columns.map((column, i) => [centers[i], yScale(column[key])]);
const last = columns.length - 1;
// The curve into the last point belongs to the bucket that is still
// filling, so everything right of the point before it is drawn as such.
const settledEdge = last > 0 ? centers[last - 1] : plot.x;
return (
<ChartRoot containerRef={containerRef}>
<svg
role="img"
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket, the last still counting`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
onMouseLeave={hovered.clear}
>
<ChartFrame
plot={plot}
yScale={yScale}
yTicks={yTicks}
xScale={xScale}
xTickValues={labelTickValues(timestamps, plot.width)}
bucketSeconds={data.bucket_seconds}
/>
<defs>
<clipPath id={`${clipId}-settled`}>
<rect x={0} y={0} width={settledEdge} height={CHART_HEIGHT} />
</clipPath>
<clipPath id={`${clipId}-counting`}>
<rect x={settledEdge} y={0} width={Math.max(0, width - settledEdge)} height={CHART_HEIGHT} />
</clipPath>
</defs>
{SERIES.map((series) => {
const points = pointsOf(series.key);
const line = smoothPath(points, plot.y, plot.bottom);
const area = areaPath(line, points, plot);
return (
<g key={series.key} data-series={series.key}>
<g clipPath={`url(#${clipId}-settled)`}>
<path d={area} fill={series.color} fillOpacity={series.fillOpacity} />
<path
d={line}
fill="none"
stroke={series.color}
strokeWidth={LINE_WIDTH}
strokeLinejoin="round"
/>
</g>
<g clipPath={`url(#${clipId}-counting)`} data-still-counting="">
<path
d={area}
fill={series.color}
fillOpacity={series.fillOpacity * STILL_COUNTING_OPACITY}
/>
<path
d={line}
fill="none"
stroke={series.color}
strokeWidth={LINE_WIDTH}
strokeLinejoin="round"
strokeDasharray={STILL_COUNTING_DASH}
/>
</g>
</g>
);
})}
{hovered.index !== null && (
<g data-hover="">
<line
x1={centers[hovered.index]}
x2={centers[hovered.index]}
y1={plot.y}
y2={plot.bottom}
{...stylex.props(styles.guide)}
/>
{SERIES.map((series) => (
<circle
key={series.key}
cx={centers[hovered.index as number]}
cy={yScale(columns[hovered.index as number][series.key])}
r={DOT_RADIUS}
fill={series.color}
{...stylex.props(styles.dot)}
/>
))}
</g>
)}
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
</svg>
{hovered.index !== null && (
<ChartTooltip
index={hovered.index}
content={tooltipOf(columns[hovered.index], hovered.index === last)}
left={centers[hovered.index]}
top={yScale(columns[hovered.index].queries)}
beside
/>
)}
<ChartLegend
entries={SERIES.map((series) => ({ key: series.key, label: series.label, color: series.color }))}
/>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per time bucket</caption>
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Queries</th>
<th scope="col">Blocked</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{columns.map((column, index) => (
<tr key={column.ts}>
<th scope="row">{bucketTitle(column.ts, index === last)}</th>
<td>{formatCount(column.queries)}</td>
<td>{formatCount(column.blocked)}</td>
<td>{formatCount(column.allowed)}</td>
</tr>
))}
</tbody>
</table>
</div>
</ChartRoot>
);
}