diff --git a/web/src/features/dashboard/TimeseriesChart.tsx b/web/src/features/dashboard/TimeseriesChart.tsx index 2debbdf..90303cc 100644 --- a/web/src/features/dashboard/TimeseriesChart.tsx +++ b/web/src/features/dashboard/TimeseriesChart.tsx @@ -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, number] { const ref = useRef(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 ( -
-
{formatTime(bar.bucket.ts)}
-
-
-
Queries
-
{bar.bucket.queries}
+
+
{formatTime(bar.bucket.ts)}
+
+
+
Queries
+
{bar.bucket.queries}
{SERIES.map((series) => ( -
-
+
+
-
{series.key === "other" ? bar.other : bar.bucket[series.key]}
+
+ {series.key === "other" ? bar.other : bar.bucket[series.key]} +
))}
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) { if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) { return ( -
+
No queries in this period.
); @@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) { const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined; return ( -
+
{compact.format(tick.value)} @@ -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) => ( {formatTick(tick.ts, data.bucket_seconds)} @@ -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 }) { ))} {hoveredBar !== undefined && } -
    +
      {SERIES.map((series) => ( -
    • +
    • ))}
    - +
    Queries per time bucket