the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0 primitives; rendering, colours and themes stay the app's own. all four charts share one hover treatment: the client chart gains the tooltip and dimming the query timeline had, the donuts gain both, an open tooltip follows a data refresh instead of going stale, and it retires when the window rolls. the timeline's third series is named allowed instead of other, and the client chart's other aggregate disappears from a window where it counted nothing. licenses gain the isc text for the bundled d3 modules.
499 lines
14 KiB
TypeScript
499 lines
14 KiB
TypeScript
/**
|
|
* 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<HTMLDivElement | null>, number] {
|
|
const ref = useRef<HTMLDivElement>(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<number>({ domain: [0, max], range, nice: true });
|
|
}
|
|
|
|
export type ValueScale = ReturnType<typeof valueScale>;
|
|
|
|
/** 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<number>({
|
|
domain: values,
|
|
range: [plot.x, plot.x + plot.width],
|
|
paddingInner: bandPaddingInner(values.length, plot.width),
|
|
});
|
|
}
|
|
|
|
export type BandScale = ReturnType<typeof bandScale>;
|
|
|
|
/**
|
|
* 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<HTMLDivElement | null> }) {
|
|
return (
|
|
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
|
No queries in this period.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ChartRoot({
|
|
containerRef,
|
|
children,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div ref={containerRef} {...stylex.props(styles.root)}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<text
|
|
x={x}
|
|
y={0}
|
|
dx={dx}
|
|
dy={dy}
|
|
textAnchor={textAnchor}
|
|
dominantBaseline={dominantBaseline}
|
|
{...stylex.props(styles.axisLabel)}
|
|
>
|
|
{formattedValue}
|
|
</text>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<text
|
|
x={x}
|
|
y={y}
|
|
dx={dx}
|
|
dy={dy}
|
|
textAnchor={textAnchor}
|
|
dominantBaseline={dominantBaseline}
|
|
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
|
>
|
|
{formattedValue}
|
|
</text>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<>
|
|
<GridRows scale={yScale} tickValues={yTicks} left={plot.x} width={plot.width} stroke={colors.border} />
|
|
<AxisLeft
|
|
scale={yScale}
|
|
left={plot.x}
|
|
tickValues={yTicks}
|
|
numTicks={Y_TICK_COUNT}
|
|
hideAxisLine
|
|
hideTicks
|
|
tickLength={0}
|
|
tickFormat={(value) => 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}
|
|
/>
|
|
<AxisBottom
|
|
scale={xScale}
|
|
top={plot.bottom}
|
|
tickValues={xTickValues}
|
|
hideTicks
|
|
tickLength={0}
|
|
stroke={colors.borderStrong}
|
|
tickFormat={(value) => 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 (
|
|
<rect
|
|
x={x}
|
|
y={y}
|
|
width={width}
|
|
height={height}
|
|
fill={fill}
|
|
strokeWidth={width > 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 (
|
|
<rect
|
|
key={value}
|
|
x={x}
|
|
y={plot.y}
|
|
width={Math.max(0, Math.min(step, right - x))}
|
|
height={plot.height}
|
|
fill="transparent"
|
|
onMouseEnter={() => 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 (
|
|
<TooltipWithBounds
|
|
key={measureKey}
|
|
left={left}
|
|
top={top}
|
|
offsetLeft={TOOLTIP_OFFSET}
|
|
offsetTop={TOOLTIP_OFFSET}
|
|
style={NO_INLINE_STYLE}
|
|
className={stylex.props(styles.tooltip).className}
|
|
>
|
|
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
|
|
<dl {...stylex.props(styles.tooltipList)}>
|
|
{content.rows.map((row) => (
|
|
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
|
|
<dt {...stylex.props(styles.tooltipTerm)}>
|
|
{row.color !== undefined && (
|
|
<span
|
|
aria-hidden="true"
|
|
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
|
|
/>
|
|
)}
|
|
{row.label}
|
|
</dt>
|
|
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</TooltipWithBounds>
|
|
);
|
|
}
|