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 { useEffect, useRef, useState } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import type { StatsTimeseries } from "@/lib/types";
|
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";
|
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
|
||||||
|
|
||||||
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
||||||
@@ -14,6 +17,108 @@ const SERIES = [
|
|||||||
const CHART_HEIGHT = 240;
|
const CHART_HEIGHT = 240;
|
||||||
const FALLBACK_WIDTH = 640;
|
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] {
|
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
@@ -46,28 +151,29 @@ function barSummary(bar: BarLayout): string {
|
|||||||
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
||||||
const centerX = bar.slot.x + bar.slot.width / 2;
|
const centerX = bar.slot.x + bar.slot.width / 2;
|
||||||
const leftHalf = centerX < chartWidth / 2;
|
const leftHalf = centerX < chartWidth / 2;
|
||||||
|
const side = leftHalf
|
||||||
|
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
|
||||||
|
: styles.tooltipRight(chartWidth - centerX + 8);
|
||||||
return (
|
return (
|
||||||
<div
|
<div {...stylex.props(styles.tooltip, side)}>
|
||||||
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"
|
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
|
||||||
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }}
|
<dl {...stylex.props(styles.tooltipList)}>
|
||||||
>
|
<div {...stylex.props(styles.tooltipRow)}>
|
||||||
<div className="font-medium">{formatTime(bar.bucket.ts)}</div>
|
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
|
||||||
<dl className="mt-1 space-y-0.5">
|
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<dt className="text-zinc-500">Queries</dt>
|
|
||||||
<dd className="tabular-nums">{bar.bucket.queries}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
{SERIES.map((series) => (
|
{SERIES.map((series) => (
|
||||||
<div key={series.key} className="flex items-center justify-between gap-4">
|
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
|
||||||
<dt className="flex items-center gap-1.5 text-zinc-500">
|
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="inline-block size-2 rounded-xs"
|
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
|
||||||
style={{ backgroundColor: series.color }}
|
|
||||||
/>
|
/>
|
||||||
{series.label}
|
{series.label}
|
||||||
</dt>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
|
|
||||||
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
No queries in this period.
|
No queries in this period.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
|
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}
|
x2={layout.plot.x + layout.plot.width}
|
||||||
y1={tick.y}
|
y1={tick.y}
|
||||||
y2={tick.y}
|
y2={tick.y}
|
||||||
className="stroke-zinc-200 dark:stroke-zinc-800"
|
{...stylex.props(styles.gridLine)}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={layout.plot.x - 6}
|
x={layout.plot.x - 6}
|
||||||
y={tick.y}
|
y={tick.y}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
className="fill-zinc-500 text-[10px] tabular-nums"
|
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||||
>
|
>
|
||||||
{compact.format(tick.value)}
|
{compact.format(tick.value)}
|
||||||
</text>
|
</text>
|
||||||
@@ -131,7 +233,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
x2={layout.plot.x + layout.plot.width}
|
x2={layout.plot.x + layout.plot.width}
|
||||||
y1={baseline}
|
y1={baseline}
|
||||||
y2={baseline}
|
y2={baseline}
|
||||||
className="stroke-zinc-300 dark:stroke-zinc-700"
|
{...stylex.props(styles.axisLine)}
|
||||||
/>
|
/>
|
||||||
{layout.xTicks.map((tick) => (
|
{layout.xTicks.map((tick) => (
|
||||||
<text
|
<text
|
||||||
@@ -139,7 +241,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
x={tick.x}
|
x={tick.x}
|
||||||
y={baseline + 14}
|
y={baseline + 14}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-zinc-500 text-[10px]"
|
{...stylex.props(styles.axisLabel)}
|
||||||
>
|
>
|
||||||
{formatTick(tick.ts, data.bucket_seconds)}
|
{formatTick(tick.ts, data.bucket_seconds)}
|
||||||
</text>
|
</text>
|
||||||
@@ -157,8 +259,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
width={rect.width}
|
width={rect.width}
|
||||||
height={rect.height}
|
height={rect.height}
|
||||||
fill={series.color}
|
fill={series.color}
|
||||||
className="stroke-zinc-50 dark:stroke-zinc-950"
|
|
||||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||||
|
{...stylex.props(styles.segment)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -179,19 +281,18 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
|
{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) => (
|
{SERIES.map((series) => (
|
||||||
<li key={series.key} className="flex items-center gap-1.5">
|
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="inline-block size-2.5 rounded-xs"
|
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
|
||||||
style={{ backgroundColor: series.color }}
|
|
||||||
/>
|
/>
|
||||||
{series.label}
|
{series.label}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<table className="sr-only">
|
<table {...stylex.props(shared.srOnly)}>
|
||||||
<caption>Queries per time bucket</caption>
|
<caption>Queries per time bucket</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user