From fe0f0026e322261ba1656844b8b7e6e5b6e6a0c2 Mon Sep 17 00:00:00 2001 From: Flotapponnier Date: Tue, 15 Sep 2026 00:56:01 +0200 Subject: [PATCH] compare pages: two-line trend chart on every shared bench card; hero drops the duplicate logo row Each compare card now carries the 7d/30d/90d history behind its two headline numbers, read from /api/series filtered to the pair, fetched when the card scrolls into view. The perp volume hero already sits under the provider header cards, so its own logo row went. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HJgbZCqjR4nvCfcJSzofbw --- src/app/compare/[slug]/page.tsx | 2 + src/components/compare-bench-card.tsx | 18 ++ src/components/compare-trend-chart.tsx | 217 ++++++++++++++++++ .../perp-volume-head-to-head-section.tsx | 13 +- 4 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 src/components/compare-trend-chart.tsx diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 474128108..0b5279bb7 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -1002,6 +1002,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 d319b6c9e..20e820661 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 } from "@/components/compare-trend-chart"; import Link from "next/link"; import type { Benchmark } from "@/types/benchmark"; import { fmtUnit, fmtValue, unitSuffix } from "@/lib/format"; @@ -71,10 +72,16 @@ export function CompareBenchCard({ bench, aName, bName, + aSlug, + bSlug, }: { bench: CompareBench; aName: string; bName: string; + /** Provider slugs, for the trend chart's /api/series filter. Optional + * so older call sites keep working without the chart. */ + aSlug?: string; + bSlug?: string; }) { const hasScopes = bench.panelScopes.length > 0; @@ -146,6 +153,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 000000000..458b206c4 --- /dev/null +++ b/src/components/compare-trend-chart.tsx @@ -0,0 +1,217 @@ +"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"; + +type SeriesPayload = { + timestamps: number[]; + providers: { slug: string; values: (number | null)[] }[]; +}; + +/** + * Two-line trend for one shared bench on a compare page: the same + * /api/series the bench page reads, filtered to the pair, so every + * compare card carries the history behind its two headline numbers + * rather than a single 24h figure. Fetched when the card scrolls into + * view (a pair shares up to a dozen benches) and cached per range. + * Hidden when neither side has a point in the window. + */ +export function CompareTrendChart({ + benchSlug, + unit, + aSlug, + bSlug, + aName, + bName, +}: { + benchSlug: string; + unit: string; + aSlug: string; + bSlug: string; + aName: string; + bName: string; +}) { + const [range, setRange] = useState("30d"); + const [data, setData] = useState>>({}); + // Starts visible when IntersectionObserver is unavailable (old + // browsers, SSR) so the chart still loads; otherwise waits for the + // card to scroll near the viewport. + const [visible, setVisible] = useState( + () => typeof IntersectionObserver === "undefined", + ); + const [hover, setHover] = useState(null); + const rootRef = useRef(null); + + useEffect(() => { + const el = rootRef.current; + if (!el || visible) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) setVisible(true); + }, + { rootMargin: "200px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [visible]); + + useEffect(() => { + if (!visible || range in data) return; + let cancelled = false; + const qs = new URLSearchParams({ range, raw: "1", providers: `${aSlug},${bSlug}` }); + fetch(`/api/series/${benchSlug}?${qs.toString()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((json: SeriesPayload | null) => { + if (cancelled) return; + setData((d) => ({ ...d, [range]: json })); + }) + .catch(() => { + if (!cancelled) setData((d) => ({ ...d, [range]: null })); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, range, benchSlug, aSlug, bSlug]); + + const payload = data[range]; + 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; + const hasA = a.some((v) => v !== null); + const hasB = b.some((v) => v !== null); + if (!hasA && !hasB) return null; + return { a, b, ts: payload.timestamps, n }; + }, [payload, aSlug, bSlug]); + + const aColor = brandColor(aSlug) ?? lineColor(0); + const bColor = brandColor(bSlug) ?? lineColor(1); + + // Nothing yet, or confirmed empty: keep the card's layout stable with + // a slim placeholder only while loading; render nothing on empty. + if (payload === null || (payload && !series)) return

; + if (!series) { + return ( +
+ ); + } + + const W = 800; + const H = 150; + const PAD_L = 8; + const PAD_R = 8; + const PAD_T = 10; + const PAD_B = 20; + const plotW = W - PAD_L - PAD_R; + const plotH = H - PAD_T - PAD_B; + const vals = [...series.a, ...series.b].filter((v): v is number => v !== null && Number.isFinite(v)); + const max = Math.max(...vals, 0); + const min = Math.min(...vals, 0); + const span = max - min || 1; + const x = (i: number) => PAD_L + (series.n <= 1 ? plotW / 2 : (i / (series.n - 1)) * plotW); + const y = (v: number) => PAD_T + plotH - ((v - min) / span) * plotH; + const path = (arr: (number | null)[]) => { + const segs: string[] = []; + let cur = ""; + arr.forEach((v, i) => { + if (v === null || !Number.isFinite(v)) { + if (cur) segs.push(cur); + cur = ""; + return; + } + cur += `${cur ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`; + }); + if (cur) segs.push(cur); + return segs.join(" "); + }; + const hv = hover !== null ? { a: series.a[hover] ?? null, b: series.b[hover] ?? null, t: series.ts[hover] } : null; + const fmt = (v: number | null) => (v === null ? "n/a" : fmtUnit(v, unit)); + const first = series.ts[0]; + const last = series.ts[series.n - 1]; + + return ( +
+
+
+ + + {aName} + + + + {bName} + + {hv && ( + + {fmtTs(hv.t, range)} ยท {fmt(hv.a)} vs {fmt(hv.b)} + + )} +
+
+ {(["7d", "30d", "90d"] as const).map((r) => ( + + ))} +
+
+ setHover(null)} + > + + + + {hover !== null && ( + + )} + {Array.from({ length: series.n }, (_, i) => ( + setHover(i)} + /> + ))} + + {fmtTs(first, range)} + + + {fmtTs(last, range)} + + +
+ ); +} + +function fmtTs(ms: number | undefined, range: Range): 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") return `${day} ${String(d.getUTCHours()).padStart(2, "0")}:00`; + return day; +} diff --git a/src/components/perp-volume-head-to-head-section.tsx b/src/components/perp-volume-head-to-head-section.tsx index 2dc6f2cd7..ee1fb35ba 100644 --- a/src/components/perp-volume-head-to-head-section.tsx +++ b/src/components/perp-volume-head-to-head-section.tsx @@ -11,7 +11,6 @@ import { brandColor } from "@/lib/brand"; import { lineColor } from "@/lib/series-colors"; import { PerpVolumeHeadToHeadChart } from "@/components/perp-volume-head-to-head"; import { PerpVolumeRatioChart } from "@/components/perp-volume-ratio-chart"; -import { ProviderLogo } from "@/components/provider-logo"; /** * Compare-page hero for two perp venues: daily perp volume head to head @@ -70,17 +69,7 @@ export async function PerpVolumeHeadToHead({

Daily perp volume, head to head

-
- - - {aName} - - vs - - - {bName} - -
+

Perpetual notional per closed UTC day, the DeFiLlama day buckets, read from each venue's own data. Ribbon under the bars marks which venue printed more that day.