diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index e0bf7e3d..c429ab2c 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -1004,6 +1004,8 @@ export default async function ComparePage({ bench={s} aName={a.name} bName={b.name} + aSlug={a.slug} + bSlug={b.slug} /> ))} diff --git a/src/components/compare-bench-card.tsx b/src/components/compare-bench-card.tsx index d319b6c9..12af663a 100644 --- a/src/components/compare-bench-card.tsx +++ b/src/components/compare-bench-card.tsx @@ -1,6 +1,7 @@ "use client"; import { Fragment } from "react"; +import { CompareTrendChart, type TrendView } from "@/components/compare-trend-chart"; import Link from "next/link"; import type { Benchmark } from "@/types/benchmark"; import { fmtUnit, fmtValue, unitSuffix } from "@/lib/format"; @@ -71,12 +72,25 @@ export function CompareBenchCard({ bench, aName, bName, + aSlug, + bSlug, }: { bench: CompareBench; aName: string; bName: string; + /** Provider slugs for the trend chart's /api/series filter. The chart + * renders only when both are given. */ + aSlug?: string; + bSlug?: string; }) { const hasScopes = bench.panelScopes.length > 0; + // Chart views mirror the bench page: headline metric first, then every + // metric panel that has a tab (panelScopes already applies that filter + // and prepends "main" when panels exist). + const trendViews: TrendView[] = + bench.panelScopes.length > 0 + ? bench.panelScopes.map((p) => ({ id: p.id, label: p.label, unit: p.unit })) + : [{ id: "main", label: bench.metric, unit: bench.unit }]; return (
@@ -146,6 +160,17 @@ export function CompareBenchCard({ )} + {aSlug && bSlug && ( + + )} + {bench.note && (

{bench.note} diff --git a/src/components/compare-trend-chart.tsx b/src/components/compare-trend-chart.tsx new file mode 100644 index 00000000..78694b6b --- /dev/null +++ b/src/components/compare-trend-chart.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { fmtUnit } from "@/lib/format"; +import { brandColor } from "@/lib/brand"; +import { lineColor } from "@/lib/series-colors"; + +type Range = "7d" | "30d" | "90d"; +const RANGES: Range[] = ["7d", "30d", "90d"]; + +/** One selectable view: the bench headline metric or one of its panels. */ +export type TrendView = { + /** "main" for the headline series, otherwise the metric_panels id. */ + id: string; + label: string; + unit: string; +}; + +type SeriesPayload = { + timestamps: number[]; + providers: { slug: string; values: (number | null)[] }[]; +}; + +/** + * History behind a compare card: the two providers' series for one + * shared bench, with the same view tabs the bench page offers (headline + * metric plus every metric panel) and a 7d / 30d / 90d range. Reads + * /api/series filtered to the pair, fetched when the card scrolls near + * the viewport, cached per (view, range) for the session. + */ +export function CompareTrendChart({ + benchSlug, + views, + aSlug, + bSlug, + aName, + bName, +}: { + benchSlug: string; + views: TrendView[]; + aSlug: string; + bSlug: string; + aName: string; + bName: string; +}) { + const [viewId, setViewId] = useState(views[0]?.id ?? "main"); + const [range, setRange] = useState("30d"); + const [cache, setCache] = useState>({}); + const [visible, setVisible] = useState( + () => typeof IntersectionObserver === "undefined", + ); + const [hover, setHover] = useState(null); + const rootRef = useRef(null); + + const view = views.find((v) => v.id === viewId) ?? views[0]; + const key = `${viewId}:${range}`; + + useEffect(() => { + const el = rootRef.current; + if (!el || visible) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) setVisible(true); + }, + { rootMargin: "240px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [visible]); + + useEffect(() => { + if (!visible || key in cache) return; + let cancelled = false; + const qs = new URLSearchParams({ range, raw: "1", providers: `${aSlug},${bSlug}` }); + if (viewId !== "main") qs.set("panel", viewId); + fetch(`/api/series/${benchSlug}?${qs.toString()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((json: SeriesPayload | null) => { + if (!cancelled) setCache((c) => ({ ...c, [key]: json })); + }) + .catch(() => { + if (!cancelled) setCache((c) => ({ ...c, [key]: null })); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, key]); + + const payload = key in cache ? cache[key] : undefined; + const series = useMemo(() => { + if (!payload) return null; + const pick = (slug: string) => payload.providers.find((p) => p.slug === slug)?.values ?? []; + const a = pick(aSlug); + const b = pick(bSlug); + const n = Math.max(a.length, b.length, payload.timestamps.length); + if (n === 0) return null; + if (!a.some((v) => v !== null) && !b.some((v) => v !== null)) return null; + return { a, b, ts: payload.timestamps, n }; + }, [payload, aSlug, bSlug]); + + const aColor = brandColor(aSlug) ?? lineColor(0); + const bColor = brandColor(bSlug) ?? lineColor(1); + const unit = view?.unit ?? ""; + const fmt = (v: number | null | undefined) => + v === null || v === undefined || !Number.isFinite(v) ? "n/a" : fmtUnit(v, unit); + + // Geometry + const W = 820; + const H = 200; + const PAD_L = 62; + const PAD_R = 14; + const PAD_T = 12; + const PAD_B = 26; + const plotW = W - PAD_L - PAD_R; + const plotH = H - PAD_T - PAD_B; + + const scale = useMemo(() => { + if (!series) return null; + const vals = [...series.a, ...series.b].filter( + (v): v is number => v !== null && Number.isFinite(v), + ); + const rawMax = Math.max(...vals); + const rawMin = Math.min(...vals); + const lo = rawMin < 0 ? niceFloor(rawMin) : 0; + const hi = rawMax > 0 ? niceCeil(rawMax) : lo === 0 ? 1 : 0; + const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => lo + f * (hi - lo)); + return { lo, hi, ticks }; + }, [series]); + + const x = (i: number) => + PAD_L + (!series || series.n <= 1 ? plotW / 2 : (i / (series.n - 1)) * plotW); + const y = (v: number) => + !scale ? PAD_T + plotH : PAD_T + plotH - ((v - scale.lo) / (scale.hi - scale.lo || 1)) * plotH; + + const linePath = (arr: (number | null)[]) => { + let d = ""; + let open = false; + arr.forEach((v, i) => { + if (v === null || !Number.isFinite(v)) { + open = false; + return; + } + d += `${open ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`; + open = true; + }); + return d; + }; + const areaPath = (arr: (number | null)[]) => { + // One closed area per contiguous run, down to the zero line. + const base = y(Math.max(0, scale?.lo ?? 0)); + let d = ""; + let start: number | null = null; + arr.forEach((v, i) => { + const ok = v !== null && Number.isFinite(v); + if (ok && start === null) { + start = i; + d += `M${x(i).toFixed(1)},${base.toFixed(1)}`; + } + if (ok) d += `L${x(i).toFixed(1)},${y(v as number).toFixed(1)}`; + const last = i === arr.length - 1; + if ((!ok || last) && start !== null) { + const endIdx = ok ? i : i - 1; + d += `L${x(endIdx).toFixed(1)},${base.toFixed(1)}Z`; + start = null; + } + }); + return d; + }; + + const lastIdx = (arr: (number | null)[]) => { + for (let i = arr.length - 1; i >= 0; i--) if (arr[i] !== null) return i; + return -1; + }; + const shownIdx = hover ?? (series ? Math.max(lastIdx(series.a), lastIdx(series.b)) : -1); + const shown = + series && shownIdx >= 0 + ? { a: series.a[shownIdx] ?? null, b: series.b[shownIdx] ?? null, t: series.ts[shownIdx] } + : null; + + const xLabels = useMemo(() => { + if (!series || series.n < 2) return []; + const idx = [0, Math.floor((series.n - 1) / 2), series.n - 1]; + return idx.map((i) => ({ i, label: fmtTs(series.ts[i], range) })); + }, [series, range]); + + return ( +

+
+ {views.length > 1 ? ( +
+ View + {views.map((v) => ( + + ))} +
+ ) : ( + + {view?.label} + + )} +
+ {RANGES.map((r) => ( + + ))} +
+
+ +
+
+ + + {aName} + {fmt(shown?.a)} + + + + {bName} + {fmt(shown?.b)} + +
+ + {shown ? (hover !== null ? fmtTs(shown.t, range, true) : `latest ยท ${fmtTs(shown.t, range, true)}`) : ""} + +
+ + {payload === undefined && ( +
+ )} + {payload !== undefined && !series && ( +

+ No series for this view over the last {range}. +

+ )} + {series && scale && ( + setHover(null)} + > + + + + + + + + + + + {scale.ticks.map((t) => ( + + + + {fmt(t)} + + + ))} + + + + + {shownIdx >= 0 && ( + + + {series.a[shownIdx] !== null && ( + + )} + {series.b[shownIdx] !== null && ( + + )} + + )} + {Array.from({ length: series.n }, (_, i) => ( + setHover(i)} + /> + ))} + {xLabels.map(({ i, label }, k) => ( + + {label} + + ))} + + )} +
+ ); +} + +function niceCeil(v: number): number { + if (v <= 0) return 0; + const exp = Math.pow(10, Math.floor(Math.log10(v))); + const m = v / exp; + const step = m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 4 ? 4 : m <= 5 ? 5 : m <= 8 ? 8 : 10; + return step * exp; +} + +function niceFloor(v: number): number { + return -niceCeil(-v); +} + +function fmtTs(ms: number | undefined, range: Range, withTime = false): string { + if (!ms) return ""; + const d = new Date(ms); + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const day = `${months[d.getUTCMonth()]} ${d.getUTCDate()}`; + if (range === "7d" || withTime) { + return `${day} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")} UTC`; + } + return day; +}