admin: overview redesign, device scope, one formatting contract (milestone 39)
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
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
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
@@ -13,14 +13,20 @@ 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 } from "@visx/scale";
|
||||
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 } from "@/ui/tokens.stylex";
|
||||
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;
|
||||
|
||||
@@ -41,12 +47,20 @@ export interface Plot {
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function plotArea(width: number): Plot {
|
||||
/**
|
||||
* 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: MARGIN.left,
|
||||
x: left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
width: Math.max(0, width - left - MARGIN.right),
|
||||
height,
|
||||
bottom: MARGIN.top + height,
|
||||
};
|
||||
@@ -86,9 +100,13 @@ export function valueScale(max: number, range: [number, number]) {
|
||||
|
||||
export type ValueScale = ReturnType<typeof valueScale>;
|
||||
|
||||
/** The tick values the grid and the value axis both draw. */
|
||||
/**
|
||||
* 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);
|
||||
return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +120,7 @@ export function bandPaddingInner(bucketCount: number, plotWidth: number): number
|
||||
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
|
||||
}
|
||||
|
||||
/** The time axis: one band per bucket, in the order the buckets were given. */
|
||||
/** 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,
|
||||
@@ -113,6 +131,19 @@ export function bandScale(values: number[], plot: Plot) {
|
||||
|
||||
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
|
||||
@@ -124,14 +155,43 @@ export function labelTickValues(values: number[], plotWidth: number): number[] {
|
||||
return values.filter((_, i) => i % step === 0);
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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);
|
||||
/**
|
||||
* 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 new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
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({
|
||||
@@ -140,7 +200,7 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -155,15 +215,8 @@ const styles = stylex.create({
|
||||
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: layers.tooltip,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -174,6 +227,12 @@ const styles = stylex.create({
|
||||
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,
|
||||
},
|
||||
@@ -201,7 +260,7 @@ const styles = stylex.create({
|
||||
height: "0.5rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
/** Dynamic: the swatch takes the series colour the SVG shapes are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
@@ -283,7 +342,7 @@ export function ChartFrame({
|
||||
plot: Plot;
|
||||
yScale: ValueScale;
|
||||
yTicks: number[];
|
||||
xScale: BandScale;
|
||||
xScale: TimeScale;
|
||||
xTickValues: number[];
|
||||
bucketSeconds: number;
|
||||
}) {
|
||||
@@ -298,7 +357,7 @@ export function ChartFrame({
|
||||
hideAxisLine
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
tickFormat={(value) => compact.format(Number(value))}
|
||||
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" }}
|
||||
@@ -320,9 +379,9 @@ export function ChartFrame({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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,
|
||||
@@ -330,59 +389,54 @@ export function StackSegment({
|
||||
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}
|
||||
fill={fill}
|
||||
strokeWidth={width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
);
|
||||
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 still selects it.
|
||||
* that pointing at the empty space above a short column or a low point 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.
|
||||
* 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 BucketOverlay({
|
||||
export function HitBands({
|
||||
plot,
|
||||
values,
|
||||
xScale,
|
||||
centers,
|
||||
onEnter,
|
||||
}: {
|
||||
plot: Plot;
|
||||
values: number[];
|
||||
xScale: BandScale;
|
||||
/** The x of each bucket's mark, in bucket order. */
|
||||
centers: number[];
|
||||
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;
|
||||
{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={value}
|
||||
x={x}
|
||||
key={index}
|
||||
x={x0}
|
||||
y={plot.y}
|
||||
width={Math.max(0, Math.min(step, right - x))}
|
||||
width={Math.max(0, x1 - x0)}
|
||||
height={plot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => onEnter(index)}
|
||||
@@ -393,13 +447,13 @@ export function BucketOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
|
||||
/** 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 bar charts, a
|
||||
* 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
|
||||
@@ -444,24 +498,41 @@ export interface TooltipContent {
|
||||
/**
|
||||
* `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.
|
||||
* 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 the tooltip keeps from its anchor point. */
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
/** 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.
|
||||
@@ -469,33 +540,88 @@ export function ChartTooltip({
|
||||
// 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={TOOLTIP_OFFSET}
|
||||
offsetTop={TOOLTIP_OFFSET}
|
||||
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
|
||||
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
|
||||
style={NO_INLINE_STYLE}
|
||||
className={stylex.props(styles.tooltip).className}
|
||||
className={stylex.props(styles.tooltipShell).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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user