/** * The plumbing the charts on Overview share: the width measurement, the scales, * the axis and grid chrome, the smoothed path, the stacked segment, 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 { useLayoutEffect, 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, scalePoint } from "@visx/scale"; import { TooltipWithBounds } from "@visx/tooltip"; import { formatBucketTime, formatCount } from "@/lib/format"; import { styles as shared } from "@/ui/styles"; import { colors, layers, metrics } from "@/ui/tokens.stylex"; export const CHART_HEIGHT = 240; export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const; /** The 10px tick label's glyph width; digits and the comma in a grouped count are all about this wide. */ export const TICK_GLYPH_PX = 6; /** The tick label's gap to the axis (`dx` below) plus a little air. */ export const TICK_LABEL_GAP_PX = 10; /** 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; } /** * The plot inside the axes. The left margin grows with the widest y label the * chart can show: counts are thousands-grouped everywhere, ticks included, so * "120,000" needs the room "120" does not. The tick above `maxValue` can add a * digit or a comma, so the width is measured on a quarter more. */ export function plotArea(width: number, maxValue = 0): Plot { const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom); const label = formatCount(Math.ceil(maxValue * 1.25)); const left = Math.max(MARGIN.left, label.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX); return { x: left, y: MARGIN.top, width: Math.max(0, width - left - MARGIN.right), height, bottom: MARGIN.top + height, }; } /** * The container's width, measured on mount and on every resize. * * `useLayoutEffect` rather than `useEffect`, because the difference is visible: * a layout effect measures and re-renders before the browser paints, so the * first painted frame is already the real width. Under `useEffect` the measured * width lands one painted frame later, and the reader sees a chart drawn at * `FALLBACK_WIDTH` snap to its container. */ export function useMeasuredWidth(): [React.RefObject, number] { const ref = useRef(null); const [width, setWidth] = useState(0); useLayoutEffect(() => { 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. A query count is a * whole number, so d3's fractional ticks — 0.5 of a query at a peak of 1 — are * dropped rather than labelled. */ export function valueTicks(scale: ValueScale): number[] { return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger); } /** * 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 of a bar chart: 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 time axis of the area chart: the first bucket on the plot's left edge and * the last on its right, so the curve never runs past its own data and the * reader can see the last point. */ export function pointScale(values: number[], plot: Plot) { return scalePoint({ domain: values, range: [plot.x, plot.x + plot.width] }); } export type PointScale = ReturnType; export type TimeScale = BandScale | PointScale; /** * 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); } /** One decimal of path precision: enough for a pixel, short enough to keep the DOM small. */ function coordinate(value: number): number { return Math.round(value * 10) / 10; } /** * A curve through every point, as cubic Béziers derived from a Catmull-Rom * spline. The control points' y is clamped to the plot, because a spline * through a spike overshoots and would otherwise draw the curve dipping below * the baseline beside a zero bucket. The curve passes exactly through each * point, so the hover dots sit at the coordinates the path was built from. */ export function smoothPath(points: [number, number][], top: number, bottom: number): string { if (points.length === 0) return ""; const clamp = (y: number) => Math.max(top, Math.min(bottom, y)); const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))]; let d = `M${coordinate(points[0][0])},${coordinate(points[0][1])}`; for (let i = 0; i < points.length - 1; i += 1) { const p0 = at(i - 1); const p1 = at(i); const p2 = at(i + 1); const p3 = at(i + 2); const c1x = p1[0] + (p2[0] - p0[0]) / 6; const c1y = clamp(p1[1] + (p2[1] - p0[1]) / 6); const c2x = p2[0] - (p3[0] - p1[0]) / 6; const c2y = clamp(p2[1] - (p3[1] - p1[1]) / 6); d += ` C${coordinate(c1x)},${coordinate(c1y)} ${coordinate(c2x)},${coordinate(c2y)} ${coordinate(p2[0])},${coordinate(p2[1])}`; } return d; } /** The same curve closed down to the baseline, for the fill under the line. */ export function areaPath(line: string, points: [number, number][], plot: Plot): string { if (points.length === 0) return ""; const first = coordinate(points[0][0]); const last = coordinate(points[points.length - 1][0]); return `${line} L${last},${plot.bottom} L${first},${plot.bottom} Z`; } const styles = stylex.create({ empty: { display: "flex", alignItems: "center", justifyContent: "center", height: CHART_HEIGHT, borderRadius: metrics.radius, 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", }, tooltip: { borderRadius: metrics.radius, 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)", }, /** The positioning shell: `TooltipWithBounds` owns its inline transform, so nothing visual may sit here. */ tooltipShell: { pointerEvents: "none", position: "absolute", zIndex: layers.tooltip, }, 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 shapes 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: TimeScale; xTickValues: number[]; bucketSeconds: number; }) { return ( <> formatCount(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. `bleed` extends the segment down into the * one drawn before it, so antialiasing cannot open a seam of ground between * two touching fills: the blank lines the owner saw across the client chart. */ export function StackSegment({ x, y, width, height, fill, bleed = 0, }: { x: number; y: number; width: number; height: number; fill: string; bleed?: number; }) { if (height <= 0) return null; return ; } /** The amount one upper segment overlaps the one under it, in plot units. */ export const STACK_BLEED = 0.5; /** * The transparent hit targets: one per bucket, spanning the whole plot height so * that pointing at the empty space above a short column or a low point still * selects it. * * The bands tile the plot edge to edge with their boundaries midway between * neighbouring marks, so no pixel belongs to no bucket and landing in a band is * already "snap to the nearest bucket". The same tiling serves band centres * (bars) and points spread edge to edge (the area chart). */ export function HitBands({ plot, centers, onEnter, }: { plot: Plot; /** The x of each bucket's mark, in bucket order. */ centers: number[]; onEnter: (index: number) => void; }) { const right = plot.x + plot.width; return ( <> {centers.map((cx, index) => { const x0 = index === 0 ? plot.x : (centers[index - 1] + cx) / 2; const x1 = index === centers.length - 1 ? right : (cx + centers[index + 1]) / 2; return ( onEnter(index)} /> ); })} ); } /** The x a bar'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 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. What it keeps is the * positioning; the box the reader sees is the element inside it. */ const NO_INLINE_STYLE = {}; /** The gap a tooltip hung from a chart's corner keeps from its anchor point. */ const CORNER_OFFSET = 8; /** * The gap a tooltip beside a point keeps from it: enough that the box never * covers the dot or the slot of data the reader is looking at. */ const BESIDE_OFFSET = 16; export function ChartTooltip({ content, index, left, top = 0, beside = false, }: { content: TooltipContent; /** Which item the tooltip names; part of what forces a fresh measurement. */ index: number; left: number; top?: number; /** * Beside the anchor at its own height rather than hung from the chart's top: * the area chart's placement, where `top` is the point's y. The centring is a * negative half-height `offsetTop`, not a CSS transform, so that the flip * `TooltipWithBounds` computes from its own rect sees where the box really * lands. It flips the box to the anchor's left when it would run past the * right edge, and above the anchor when it would run past the bottom. */ beside?: boolean; }) { // `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("|"); const box = useRef(null); const [height, setHeight] = useState(0); // The centring offset is half the box's own height, so it cannot be known // before the box exists. `measureKey` remounts the whole tooltip per content // change, so one measurement per mount covers every size the box takes. useLayoutEffect(() => { if (box.current) setHeight(box.current.offsetHeight); }, []); return (
{content.title}
{content.rows.map((row) => (
{row.color !== undefined && (
{row.value}
))}
); } const legendStyles = stylex.create({ legend: { marginTop: "0.75rem", display: "flex", flexWrap: "wrap", columnGap: "1rem", rowGap: "0.25rem", listStyleType: "none", padding: 0, margin: 0, fontSize: "0.75rem", lineHeight: "1rem", color: colors.textSecondary, }, item: { display: "flex", alignItems: "center", gap: "0.375rem", }, swatch: { display: "inline-block", width: "0.625rem", height: "0.625rem", borderRadius: "0.125rem", }, swatchColor: (color: string) => ({ backgroundColor: color }), }); /** The key under a chart: one swatch and one name per drawn series, in drawing order. */ export function ChartLegend({ entries }: { entries: { key: string; label: string; color: string }[] }) { return (
    {entries.map((entry) => (
  • ))}
); }