/**
* 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 ;
}
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 (
{hovered.index !== null && (
)}
({ key: series.key, label: series.label, color: series.color }))}
/>