Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only. GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry. All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else. Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash. Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round. Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
628 lines
20 KiB
TypeScript
628 lines
20 KiB
TypeScript
/**
|
|
* 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<HTMLDivElement | null>, number] {
|
|
const ref = useRef<HTMLDivElement>(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<number>({ domain: [0, max], range, nice: true });
|
|
}
|
|
|
|
export type ValueScale = ReturnType<typeof valueScale>;
|
|
|
|
/**
|
|
* 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<number>({
|
|
domain: values,
|
|
range: [plot.x, plot.x + plot.width],
|
|
paddingInner: bandPaddingInner(values.length, plot.width),
|
|
});
|
|
}
|
|
|
|
export type BandScale = ReturnType<typeof bandScale>;
|
|
|
|
/**
|
|
* 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<number>({ domain: values, range: [plot.x, plot.x + plot.width] });
|
|
}
|
|
|
|
export type PointScale = ReturnType<typeof pointScale>;
|
|
|
|
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<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, shared.tabularNums)}
|
|
>
|
|
{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: TimeScale;
|
|
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) => 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}
|
|
/>
|
|
<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. `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 <rect x={x} y={y} width={width} height={height + bleed} fill={fill} />;
|
|
}
|
|
|
|
/** 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 (
|
|
<rect
|
|
key={index}
|
|
x={x0}
|
|
y={plot.y}
|
|
width={Math.max(0, x1 - x0)}
|
|
height={plot.height}
|
|
fill="transparent"
|
|
onMouseEnter={() => 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<HTMLDivElement>(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 (
|
|
<TooltipWithBounds
|
|
key={measureKey}
|
|
left={left}
|
|
top={top}
|
|
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
|
|
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
|
|
style={NO_INLINE_STYLE}
|
|
className={stylex.props(styles.tooltipShell).className}
|
|
>
|
|
<div ref={box} {...stylex.props(styles.tooltip)}>
|
|
<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>
|
|
</div>
|
|
</TooltipWithBounds>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ul {...stylex.props(legendStyles.legend)}>
|
|
{entries.map((entry) => (
|
|
<li key={entry.key} {...stylex.props(legendStyles.item)}>
|
|
<span
|
|
aria-hidden="true"
|
|
{...stylex.props(legendStyles.swatch, legendStyles.swatchColor(entry.color))}
|
|
/>
|
|
{entry.label}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|