milestone 23 s3: convert features/dashboard/TimeseriesChart.tsx to stylex
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user