milestone 23 s3: convert features/dashboard/TimeseriesChart.tsx to stylex

This commit is contained in:
2026-08-12 22:42:11 +02:00
parent 15c895e180
commit 1cd8f71a9b
+131 -30
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
// Series colors validated for CVD separation and 3:1 surface contrast in both
@@ -14,6 +17,108 @@ const SERIES = [
const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640;
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,
},
chartRoot: {
position: "relative",
},
tooltip: {
pointerEvents: "none",
position: "absolute",
top: "0.5rem",
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)",
},
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
tooltipLeft: (left: number) => ({ left, right: null }),
tooltipRight: (right: number) => ({ left: null, right }),
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",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
swatchSmall: {
width: "0.5rem",
height: "0.5rem",
},
swatchLarge: {
width: "0.625rem",
height: "0.625rem",
},
gridLine: {
stroke: colors.border,
},
axisLine: {
stroke: colors.borderStrong,
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
});
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@@ -46,28 +151,29 @@ function barSummary(bar: BarLayout): string {
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
const centerX = bar.slot.x + bar.slot.width / 2;
const leftHalf = centerX < chartWidth / 2;
const side = leftHalf
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
: styles.tooltipRight(chartWidth - centerX + 8);
return (
<div
className="pointer-events-none absolute top-2 z-10 rounded border border-zinc-200 bg-white px-3 py-2 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }}
>
<div className="font-medium">{formatTime(bar.bucket.ts)}</div>
<dl className="mt-1 space-y-0.5">
<div className="flex justify-between gap-4">
<dt className="text-zinc-500">Queries</dt>
<dd className="tabular-nums">{bar.bucket.queries}</dd>
<div {...stylex.props(styles.tooltip, side)}>
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
<dl {...stylex.props(styles.tooltipList)}>
<div {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
</div>
{SERIES.map((series) => (
<div key={series.key} className="flex items-center justify-between gap-4">
<dt className="flex items-center gap-1.5 text-zinc-500">
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
<span
aria-hidden="true"
className="inline-block size-2 rounded-xs"
style={{ backgroundColor: series.color }}
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
/>
{series.label}
</dt>
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd>
<dd {...stylex.props(shared.tabularNums)}>
{series.key === "other" ? bar.other : bar.bucket[series.key]}
</dd>
</div>
))}
</dl>
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
return (
<div
ref={containerRef}
className="flex items-center justify-center rounded border border-dashed border-zinc-300 text-sm text-zinc-500 dark:border-zinc-700"
style={{ height: CHART_HEIGHT }}
>
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
@@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
return (
<div ref={containerRef} className="relative">
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
<svg
role="img"
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
@@ -113,14 +215,14 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={tick.y}
y2={tick.y}
className="stroke-zinc-200 dark:stroke-zinc-800"
{...stylex.props(styles.gridLine)}
/>
<text
x={layout.plot.x - 6}
y={tick.y}
textAnchor="end"
dominantBaseline="middle"
className="fill-zinc-500 text-[10px] tabular-nums"
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{compact.format(tick.value)}
</text>
@@ -131,7 +233,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={baseline}
y2={baseline}
className="stroke-zinc-300 dark:stroke-zinc-700"
{...stylex.props(styles.axisLine)}
/>
{layout.xTicks.map((tick) => (
<text
@@ -139,7 +241,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x={tick.x}
y={baseline + 14}
textAnchor="middle"
className="fill-zinc-500 text-[10px]"
{...stylex.props(styles.axisLabel)}
>
{formatTick(tick.ts, data.bucket_seconds)}
</text>
@@ -157,8 +259,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
width={rect.width}
height={rect.height}
fill={series.color}
className="stroke-zinc-50 dark:stroke-zinc-950"
strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
);
})}
@@ -179,19 +281,18 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
))}
</svg>
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<ul {...stylex.props(styles.legend)}>
{SERIES.map((series) => (
<li key={series.key} className="flex items-center gap-1.5">
<li key={series.key} {...stylex.props(styles.legendItem)}>
<span
aria-hidden="true"
className="inline-block size-2.5 rounded-xs"
style={{ backgroundColor: series.color }}
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
/>
{series.label}
</li>
))}
</ul>
<table className="sr-only">
<table {...stylex.props(shared.srOnly)}>
<caption>Queries per time bucket</caption>
<thead>
<tr>