/** * The plumbing the two bar charts on Overview share: the width measurement, the * scales, the axis and grid chrome, the segment separator, the per-bucket hit * target and the tooltip. * * Geometry is visx's; the chrome is ours. visx's own axis defaults draw tick * marks, an axis line on both axes and Arial 10px in #222, none of which this * app wants, so every wrapper here turns those off and dresses the result in * StyleX tokens instead. */ import { useEffect, useRef, useState } from "react"; import * as stylex from "@stylexjs/stylex"; import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis"; import { GridRows } from "@visx/grid"; import { scaleBand, scaleLinear } from "@visx/scale"; import { TooltipWithBounds } from "@visx/tooltip"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; export const CHART_HEIGHT = 240; export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const; /** The width a chart draws at before a measurement exists — jsdom, and the first paint. */ const FALLBACK_WIDTH = 640; /** How many pixels one x-axis label needs to itself before the labels thin out. */ const MIN_X_LABEL_PX = 90; /** The gap between two touching columns, in pixels of the drawn plot. */ const BAR_GAP_PX = 2; const Y_TICK_COUNT = 5; export interface Plot { x: number; y: number; width: number; height: number; /** The y of the baseline: the bottom edge of the plot. */ bottom: number; } export function plotArea(width: number): Plot { const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom); return { x: MARGIN.left, y: MARGIN.top, width: Math.max(0, width - MARGIN.left - MARGIN.right), height, bottom: MARGIN.top + height, }; } /** * The container's width, measured on mount and on every resize. Deliberately * `useEffect` rather than `useLayoutEffect`: the first paint draws at the * fallback width and the measured width lands a frame later, which is the * timing the charts have always had. */ export function useMeasuredWidth(): [React.RefObject, number] { const ref = useRef(null); const [width, setWidth] = useState(0); useEffect(() => { const el = ref.current; if (el === null) return; setWidth(el.clientWidth); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => setWidth(el.clientWidth)); observer.observe(el); return () => observer.disconnect(); }, []); return [ref, width > 0 ? width : FALLBACK_WIDTH]; } /** * The value axis. `nice: true` rounds the domain outward, so the `max` passed * here is the data's own maximum and not a pre-rounded one. */ export function valueScale(max: number, range: [number, number]) { return scaleLinear({ domain: [0, max], range, nice: true }); } export type ValueScale = ReturnType; /** The tick values the grid and the value axis both draw. */ export function valueTicks(scale: ValueScale): number[] { return scale.ticks(Y_TICK_COUNT); } /** * How wide the gap between two columns is, as a fraction of one column's slot. * A constant would make the gap grow with the column: 30 daily columns and 60 * minute columns are drawn at the same pixel gap, so the fraction has to be * computed from how many columns share the plot. */ export function bandPaddingInner(bucketCount: number, plotWidth: number): number { if (bucketCount === 0 || plotWidth <= 0) return 0; return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth); } /** The time axis: one band per bucket, in the order the buckets were given. */ export function bandScale(values: number[], plot: Plot) { return scaleBand({ domain: values, range: [plot.x, plot.x + plot.width], paddingInner: bandPaddingInner(values.length, plot.width), }); } export type BandScale = ReturnType; /** * The subset of bucket timestamps that get an x-axis label. A 30-day window is * 30 columns and a 1-hour window is 60, so at narrow widths the labels have to * thin out rather than overprint each other. */ export function labelTickValues(values: number[], plotWidth: number): number[] { if (values.length === 0 || plotWidth <= 0) return values.slice(0, 1); const step = Math.max(1, Math.ceil((values.length * MIN_X_LABEL_PX) / plotWidth)); return values.filter((_, i) => i % step === 0); } const compact = new Intl.NumberFormat(undefined, { notation: "compact" }); export function formatBucketTime(ts: number, bucketSeconds: number): string { const date = new Date(ts * 1000); if (bucketSeconds >= 86_400) { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date); } return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date); } const styles = stylex.create({ empty: { display: "flex", alignItems: "center", justifyContent: "center", height: CHART_HEIGHT, borderRadius: "0.25rem", borderWidth: 1, borderStyle: "dashed", borderColor: colors.borderStrong, fontSize: "0.875rem", lineHeight: "1.25rem", color: colors.textMuted, }, root: { position: "relative", }, axisLabel: { fill: colors.textMuted, fontSize: "10px", }, /** The hairline separating touching segments is the page ground, not a colour. */ segment: { stroke: colors.surface, }, tooltip: { pointerEvents: "none", position: "absolute", zIndex: 10, borderRadius: "0.25rem", borderWidth: 1, borderStyle: "solid", borderColor: colors.borderStrong, backgroundColor: colors.surfaceRaised, paddingInline: "0.75rem", paddingBlock: "0.5rem", fontSize: "0.75rem", lineHeight: "1rem", boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", }, tooltipTitle: { fontWeight: 500, }, tooltipList: { display: "flex", flexDirection: "column", gap: "0.125rem", marginTop: "0.25rem", }, tooltipRow: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "1rem", }, tooltipTerm: { display: "flex", alignItems: "center", gap: "0.375rem", color: colors.textMuted, }, swatch: { display: "inline-block", width: "0.5rem", height: "0.5rem", borderRadius: "0.125rem", }, /** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */ swatchColor: (color: string) => ({ backgroundColor: color }), }); /** Both charts' "nothing here" state, which is also where the ref to measure lives. */ export function EmptyChart({ containerRef }: { containerRef: React.RefObject }) { return (
No queries in this period.
); } export function ChartRoot({ containerRef, children, }: { containerRef: React.RefObject; children: React.ReactNode; }) { return (
{children}
); } /** * visx's `Ticks` derives a label's `y` from a guessed font size, which is wrong * here because the size comes from a stylesheet it cannot read. The label is * therefore placed by the axis group's own transform plus the `dy` the caller * passes, and the guessed `y` is dropped. */ function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) { return ( {formattedValue} ); } /** As `TickLabel`, but keeping the scaled tick position the vertical axis puts in `y`. */ function ValueTickLabel({ x, y, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) { return ( {formattedValue} ); } /** * The grid and the two axes. The grid lines and the value labels are computed * from one tick array, so every drawn line has a number beside it and no number * floats without one. */ export function ChartFrame({ plot, yScale, yTicks, xScale, xTickValues, bucketSeconds, }: { plot: Plot; yScale: ValueScale; yTicks: number[]; xScale: BandScale; xTickValues: number[]; bucketSeconds: number; }) { return ( <> compact.format(Number(value))} // `dy` overrides AxisLeft's own 0.25em nudge, which would double up // with the middle baseline this app centres its value labels on. tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }} tickComponent={ValueTickLabel} /> formatBucketTime(Number(value), bucketSeconds)} tickLabelProps={{ dy: "16px", textAnchor: "middle" }} tickComponent={TickLabel} /> ); } /** * One segment of a stacked column. The separator is drawn only once the column * is wide enough for two neighbouring segments to read as two shapes; below * that it would be most of the bar. */ export function StackSegment({ x, y, width, height, fill, }: { x: number; y: number; width: number; height: number; fill: string; }) { if (height <= 0) return null; return ( 3 ? 1 : 0} {...stylex.props(styles.segment)} /> ); } /** * The transparent hit targets: one per bucket, spanning the whole plot height so * that pointing at the empty space above a short column still selects it. * * A slot is a whole band step, gap included, so that no pixel between two * columns belongs to neither. The last slot is clipped to the plot's right edge: * the band scale spends the trailing gap on nothing, and a full-step rect there * would reach into the right margin. */ export function BucketOverlay({ plot, values, xScale, onEnter, }: { plot: Plot; values: number[]; xScale: BandScale; onEnter: (index: number) => void; }) { const step = xScale.step(); const right = plot.x + plot.width; return ( <> {values.map((value, index) => { const x = xScale(value) ?? plot.x; return ( onEnter(index)} /> ); })} ); } /** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */ export function slotCenter(xScale: BandScale, value: number, plot: Plot): number { return (xScale(value) ?? plot.x) + xScale.step() / 2; } /** * Which item the pointer is on, and nothing else — a bucket in the bar charts, a * slice in the donuts. * * Deliberately not `useTooltip`: holding the hovered item's numbers and screen * position in state decouples them from the data. The window refreshes every * half minute and the container can be resized under an open tooltip, and either * one leaves a stored copy describing an item that has moved or changed. Only * the index is kept, so the caller reads the values and the coordinates out of * the render it is currently drawing. * * `windowKey` is what makes a changing set safe: when the items themselves * change, index `i` no longer names the one the pointer was over, so the * selection made against the old key is dropped and the tooltip goes away. */ export function useActiveIndex(windowKey: string) { const [selection, setSelection] = useState<{ index: number; key: string } | null>(null); // A selection belongs to the window it was made in. Deleting it as soon as the // key moves on is what stops a returning key — a resize back to a previous // width, a poll that restores a bucket count — from reviving a hover the // pointer never made. if (selection !== null && selection.key !== windowKey) setSelection(null); return { index: selection !== null && selection.key === windowKey ? selection.index : null, show: (index: number) => setSelection({ index, key: windowKey }), clear: () => setSelection(null), }; } export interface TooltipRow { key: string; label: string; /** Omitted for a row that names no drawn shape, such as a bucket's total. */ color?: string; value: string; } /** What a tooltip says, whichever chart opened it: a heading and its rows. */ export interface TooltipContent { title: string; rows: TooltipRow[]; } /** * `TooltipWithBounds` positions itself with an inline transform and drops it * when `unstyled` is set, so the default look is replaced by handing it an empty * style object rather than by turning styling off. */ const NO_INLINE_STYLE = {}; /** The gap the tooltip keeps from its anchor point. */ const TOOLTIP_OFFSET = 8; export function ChartTooltip({ content, index, left, top = 0, }: { content: TooltipContent; /** Which item the tooltip names; part of what forces a fresh measurement. */ index: number; left: number; top?: number; }) { // `withBoundingRects` measures once, in `componentDidMount`, and never again, // so every content change needs its own mount to be measured at its own size. // The index alone is not enough: a refresh can widen the same item's text past // a digit boundary, or resolve a client's name, and the stale width would place // it wrongly at the right edge. const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|"); return (
{content.title}
{content.rows.map((row) => (
{row.color !== undefined && (
{row.value}
))}
); }