diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx
index 47412810..0b5279bb 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 d319b6c9..20e82066 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 00000000..458b206c
--- /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) => (
+ setRange(r)}
+ className={`px-2 py-0.5 ${range === r ? "bg-ink text-paper" : "text-ink-soft hover:text-ink"}`}
+ >
+ {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 2dc6f2cd..ee1fb35b 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.