231 lines
6.3 KiB
TypeScript
231 lines
6.3 KiB
TypeScript
import * as stylex from "@stylexjs/stylex";
|
|
import { Group } from "@visx/group";
|
|
import { BarStack } from "@visx/shape";
|
|
import { formatTime } from "@/lib/format";
|
|
import type { Bucket } from "@/lib/types";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
import {
|
|
BucketOverlay,
|
|
CHART_HEIGHT,
|
|
ChartFrame,
|
|
ChartRoot,
|
|
ChartTooltip,
|
|
EmptyChart,
|
|
StackSegment,
|
|
bandScale,
|
|
labelTickValues,
|
|
plotArea,
|
|
slotCenter,
|
|
useActiveIndex,
|
|
useMeasuredWidth,
|
|
valueScale,
|
|
valueTicks,
|
|
type TooltipContent,
|
|
} from "./chartKit";
|
|
|
|
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
|
// modes (Tailwind red-500 / blue-500 / emerald-600; same hex light and dark).
|
|
// The third key stays `other` — it is the colour key and the response field, and
|
|
// renaming it would repaint the series. Only what the reader sees is "Allowed".
|
|
const SERIES = [
|
|
{ key: "blocked", label: "Blocked", color: "#ef4444" },
|
|
{ key: "cached", label: "Cached", color: "#059669" },
|
|
{ key: "other", label: "Allowed", color: "#3b82f6" },
|
|
] as const;
|
|
|
|
type Series = (typeof SERIES)[number];
|
|
type SeriesKey = Series["key"];
|
|
|
|
const SERIES_COLOR: Record<SeriesKey, string> = {
|
|
blocked: SERIES[0].color,
|
|
cached: SERIES[1].color,
|
|
other: SERIES[2].color,
|
|
};
|
|
|
|
const styles = stylex.create({
|
|
legend: {
|
|
marginTop: "0.5rem",
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
columnGap: "1rem",
|
|
rowGap: "0.25rem",
|
|
fontSize: "0.75rem",
|
|
lineHeight: "1rem",
|
|
color: colors.textSecondary,
|
|
},
|
|
legendItem: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.375rem",
|
|
},
|
|
swatch: {
|
|
display: "inline-block",
|
|
width: "0.625rem",
|
|
height: "0.625rem",
|
|
borderRadius: "0.125rem",
|
|
},
|
|
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
|
swatchColor: (color: string) => ({ backgroundColor: color }),
|
|
});
|
|
|
|
interface Column {
|
|
ts: number;
|
|
queries: number;
|
|
blocked: number;
|
|
cached: number;
|
|
/** queries - blocked - cached, clamped at 0. */
|
|
other: number;
|
|
}
|
|
|
|
function columnsOf(buckets: Bucket[]): Column[] {
|
|
return buckets.map((bucket) => ({
|
|
ts: bucket.ts,
|
|
queries: bucket.queries,
|
|
blocked: bucket.blocked,
|
|
cached: bucket.cached,
|
|
other: Math.max(0, bucket.queries - bucket.blocked - bucket.cached),
|
|
}));
|
|
}
|
|
|
|
function tooltipOf(column: Column): TooltipContent {
|
|
return {
|
|
title: formatTime(column.ts),
|
|
rows: [
|
|
{ key: "queries", label: "Queries", value: String(column.queries) },
|
|
...SERIES.map((series) => ({
|
|
key: series.key,
|
|
label: series.label,
|
|
color: series.color,
|
|
value: String(column[series.key]),
|
|
})),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
|
|
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 plot = plotArea(width);
|
|
const xScale = bandScale(timestamps, plot);
|
|
// The scale is the reported total rather than the stack's own sum: blocked
|
|
// and cached are parts of `queries`, which a clamped `other` can undercount.
|
|
const yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
|
|
const yTicks = valueTicks(yScale);
|
|
|
|
return (
|
|
<ChartRoot containerRef={containerRef}>
|
|
<svg
|
|
role="img"
|
|
aria-label={`Queries over time, ${data.buckets.length} buckets: ${SERIES.map((series) => series.label.toLowerCase()).join(", ")} queries per bucket`}
|
|
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}
|
|
/>
|
|
<BarStack<Column, SeriesKey>
|
|
data={columns}
|
|
keys={SERIES.map((series) => series.key)}
|
|
x={(column) => column.ts}
|
|
xScale={xScale}
|
|
yScale={yScale}
|
|
color={(key) => SERIES_COLOR[key]}
|
|
>
|
|
{(stacks) =>
|
|
columns.map((column, index) => (
|
|
<Group
|
|
key={column.ts}
|
|
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
|
>
|
|
{stacks.map((stack) => {
|
|
const bar = stack.bars[index];
|
|
return (
|
|
<StackSegment
|
|
key={stack.key}
|
|
x={bar.x}
|
|
y={bar.y}
|
|
width={bar.width}
|
|
height={bar.height}
|
|
fill={bar.color}
|
|
/>
|
|
);
|
|
})}
|
|
</Group>
|
|
))
|
|
}
|
|
</BarStack>
|
|
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
|
</svg>
|
|
{hovered.index !== null && (
|
|
<ChartTooltip
|
|
index={hovered.index}
|
|
content={tooltipOf(columns[hovered.index])}
|
|
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
|
/>
|
|
)}
|
|
<ul {...stylex.props(styles.legend)}>
|
|
{SERIES.map((series) => (
|
|
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
|
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(series.color))} />
|
|
{series.label}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div {...stylex.props(shared.srOnly)}>
|
|
<table>
|
|
<caption>Queries per time bucket</caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Time</th>
|
|
<th scope="col">Queries</th>
|
|
{SERIES.map((series) => (
|
|
<th key={series.key} scope="col">
|
|
{series.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{columns.map((column) => (
|
|
<tr key={column.ts}>
|
|
<th scope="row">{formatTime(column.ts)}</th>
|
|
<td>{column.queries}</td>
|
|
{SERIES.map((series) => (
|
|
<td key={series.key}>{column[series.key]}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</ChartRoot>
|
|
);
|
|
}
|