255 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|