/** * Client activity over the same window as the query-volume chart: one stacked * series per named client, plus everything outside the top eight as "Other". * * The x-axis is derived from this response's own `since` and `bucket_seconds`, * which the API aligns with the timeseries buckets, so the two charts stack * directly above one another and a spike in one is at the same horizontal * position in the other. Colour goes by rank (`seriesColors.ts`): the busiest * client wears the first palette hue, and the legend names every band. */ import * as stylex from "@stylexjs/stylex"; import { Group } from "@visx/group"; import { BarStack } from "@visx/shape"; import { formatCount, formatTime } from "@/lib/format"; import type { OverviewClientSeries } from "@/lib/types"; import { styles as shared } from "@/ui/styles"; import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames"; import { CHART_HEIGHT, ChartFrame, ChartLegend, ChartRoot, ChartTooltip, EmptyChart, HitBands, STACK_BLEED, StackSegment, bandScale, labelTickValues, plotArea, slotCenter, useActiveIndex, useMeasuredWidth, valueScale, valueTicks, type TooltipContent, } from "./chartKit"; import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors"; /** How many named clients a bucket's tooltip lists before the aggregate and the total. */ const TOOLTIP_CLIENTS = 4; interface Series { key: string; label: string; color: string; buckets: number[]; } /** * "Other" last, so it sits at the top of every column rather than under a client. * It is always a series, even at zero across the window (ui-visual-redesign.md: * eight named clients plus Other): a reader comparing two scopes sees the same * legend in both, and a zero says the named clients were the whole story. */ function seriesOf(data: ClientChartData, names: ClientNames): Series[] { const named = data.clients.map((client, rank) => ({ key: clientKey(client.client), // The name if the client is registered under one, the address otherwise — // the same precedence and the same lookup the query tables use. label: clientLabel(client.client, names)?.text ?? client.client, color: clientSeriesColor(rank), buckets: client.buckets, })); return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }]; } /** * 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 ClientChartData { since: number; bucket_seconds: number; clients: OverviewClientSeries[]; other: number[]; } /** One column: the timestamp plus one entry per series, keyed by the series key. */ type Column = { ts: number } & Record; export default function ClientChart({ data }: { data: ClientChartData }) { 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.other.length}:${width}`); const names = useClientNames(); const series = seriesOf(data, names); const bucketCount = data.other.length; const columns: Column[] = Array.from({ length: bucketCount }, (_, i) => { const column: Column = { ts: data.since + i * data.bucket_seconds }; for (const one of series) column[one.key] = one.buckets[i] ?? 0; return column; }); if (bucketCount === 0 || columns.every((column) => series.every((one) => column[one.key] === 0))) { return ; } const timestamps = columns.map((column) => column.ts); const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0)); const plot = plotArea(width, Math.max(...totals)); const xScale = bandScale(timestamps, plot); // Every series here is a disjoint part of the whole rather than a highlighted // subset of a separately reported total, so the tallest column's own sum is // the scale. const yScale = valueScale(Math.max(...totals), [plot.bottom, plot.y]); const yTicks = valueTicks(yScale); const colorOf = new Map(series.map((one) => [one.key, one.color])); const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot)); /** * The bucket's busiest clients, then Other, then the total — eight named * rows would be a table, and the reader pointing at a spike wants to know * who made it. Other is always there, at zero when the named clients were * the whole bucket, so the rows read the same from bucket to bucket. */ function tooltipOf(index: number): TooltipContent { const column = columns[index]; const named = series .filter((one) => one.key !== OTHER_KEY && column[one.key] > 0) .sort((a, b) => column[b.key] - column[a.key]) .slice(0, TOOLTIP_CLIENTS); const other = series.find((one) => one.key === OTHER_KEY); const rows = [...named, ...(other !== undefined ? [other] : [])].map((one) => ({ key: one.key, label: one.label, color: one.color, value: formatCount(column[one.key]), })); return { title: formatTime(column.ts), rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }], }; } return ( data={columns} keys={series.map((one) => one.key)} x={(column) => column.ts} xScale={xScale} yScale={yScale} color={(key) => colorOf.get(key) ?? seriesColor(key)} > {(stacks) => columns.map((column, index) => ( {stacks.map((stack) => { const bar = stack.bars[index]; const restsOnAnother = bar.y + bar.height < plot.bottom - STACK_BLEED; return ( ); })} )) } {hovered.index !== null && ( )}
{series.map((one) => ( ))} {columns.map((column) => ( {series.map((one) => ( ))} ))}
Queries per client per time bucket
Time {one.label}
{formatTime(column.ts)}{formatCount(column[one.key])}
); }