diff --git a/src/app/hyperliquid/page.tsx b/src/app/hyperliquid/page.tsx index 215c60f3d..1edc401b4 100644 --- a/src/app/hyperliquid/page.tsx +++ b/src/app/hyperliquid/page.tsx @@ -1,6 +1,11 @@ import Link from "next/link"; -import { fetchHlCohort, fetchHlHip3Cohort } from "@/lib/hl-builder-stats"; +import { + fetchHlCohort, + fetchHlHip3Cohort, + fetchHlHistory, +} from "@/lib/hl-builder-stats"; import { HlHubTabs } from "@/components/hl-hub-tabs"; +import { HlHistoryChart } from "@/components/hl-history-chart"; import { pageMetadata } from "@/lib/page-metadata"; import { safeJsonLd } from "@/lib/jsonld"; @@ -33,9 +38,10 @@ export const metadata: import("next").Metadata = pageMetadata({ export const revalidate = 60; export default async function HyperliquidHubPage() { - const [frontends, hip3] = await Promise.all([ + const [frontends, hip3, history] = await Promise.all([ fetchHlCohort(), fetchHlHip3Cohort(), + fetchHlHistory(), ]); const breadcrumbLd = { @@ -160,6 +166,20 @@ export default async function HyperliquidHubPage() { cohorts publish to the same Prom; the bench pages document the per-row formulas.

+ + {history && history.frontends.length > 0 && ( +
+

+ 12-month evolution — top {history.frontends.length} frontends by fees +

+

+ Rolling 30d fees and volume per frontend, daily-stepped over + the last 365 days. Same Prom gauges as the leaderboard above, + sampled once per UTC day. +

+ +
+ )} ) : (

diff --git a/src/components/hl-history-chart.tsx b/src/components/hl-history-chart.tsx new file mode 100644 index 000000000..4ef3a83ef --- /dev/null +++ b/src/components/hl-history-chart.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import type { + HlHistoryFrontend, + HlHistoryPoint, + HlHistorySummary, +} from "@/lib/hl-builder-stats"; + +/** + * 12-month evolution chart for the top ~10 HL frontends. Two toggleable + * metrics (fees / volume, both 30d rolling), one line per frontend. + * Renders as a single SVG with a shared linear Y scale, so a viewer can + * eyeball share-of-market swings without a legend hunt. + * + * Design goals: + * - Stays a single SVG. No recharts / D3. Data volume is ~10 × 365 + * points, well inside SVG's cheap-to-render window. + * - Gaps: `v === null` points break the line rather than dropping to + * zero. Matches the harness' "no sample this UTC day" semantic and + * keeps early-history cohorts (post-launch) from starting from an + * artificial floor. + * - Colours: 10-slot OCB palette, cycled if the cohort ever grows past + * 10. Hovered line lifts to full opacity; the rest dim. + * - Crosshair tooltip lists every frontend's value at the hovered + * timestamp, ranked by value so the top of the pack is always at + * eye level. + */ + +const COLORS = [ + "#9d65ff", // violet + "#ff8a3d", // orange + "#22c55e", // emerald + "#38bdf8", // sky + "#f43f5e", // rose + "#eab308", // amber + "#14b8a6", // teal + "#a855f7", // fuchsia + "#f97316", // deep orange + "#0ea5e9", // blue +]; + +type Metric = "fees" | "volume"; + +export function HlHistoryChart({ history }: { history: HlHistorySummary }) { + const [metric, setMetric] = useState("fees"); + const [pinnedSlug, setPinnedSlug] = useState(null); + + const activeFrontends = history.frontends; + if (activeFrontends.length === 0) { + return ( +

+ No history samples yet — the backfill is still populating. +

+ ); + } + + return ( +
+
+
+

+ Last 12 months · rolling 30d +

+

+ Daily-stepped snapshot of the top {activeFrontends.length} HL + frontends +

+
+
+ + +
+
+ + + +
+ {activeFrontends.map((f, i) => { + const color = COLORS[i % COLORS.length]; + const pinned = pinnedSlug === f.slug; + return ( + + ); + })} +
+
+ ); +} + +function ChartCanvas({ + frontends, + metric, + pinnedSlug, +}: { + frontends: HlHistoryFrontend[]; + metric: Metric; + pinnedSlug: string | null; +}) { + const W = 1100; + const H = 360; + const PAD_L = 68; + const PAD_R = 20; + const PAD_T = 20; + const PAD_B = 44; + const plotW = W - PAD_L - PAD_R; + const plotH = H - PAD_T - PAD_B; + + const seriesOf = (f: HlHistoryFrontend): HlHistoryPoint[] => + metric === "fees" ? f.fees30d : f.volume30d; + + // Shared time axis: derive from the longest series so a frontend that + // launched mid-window still lines up on the shared calendar. The + // fallback (used only when every frontend has an empty series) reads + // the max `t` we can infer from the first non-empty series; if + // everything is empty we degrade to a 0..1 span so the SVG still lays + // out without pulling Date.now() inside a render-time hook. + const tRange = useMemo(() => { + let tMin = Number.POSITIVE_INFINITY; + let tMax = Number.NEGATIVE_INFINITY; + for (const f of frontends) { + for (const p of seriesOf(f)) { + if (p.t < tMin) tMin = p.t; + if (p.t > tMax) tMax = p.t; + } + } + if (!Number.isFinite(tMin) || !Number.isFinite(tMax) || tMin === tMax) { + return { tMin: 0, tMax: 1 }; + } + return { tMin, tMax }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [frontends, metric]); + + const yMax = useMemo(() => { + let m = 0; + for (const f of frontends) { + for (const p of seriesOf(f)) { + if (p.v !== null && p.v > m) m = p.v; + } + } + return niceMax(m); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [frontends, metric]); + + const xFor = (t: number) => { + const span = tRange.tMax - tRange.tMin || 1; + return PAD_L + ((t - tRange.tMin) / span) * plotW; + }; + const yFor = (v: number) => + PAD_T + plotH - (yMax > 0 ? (v / yMax) * plotH : 0); + + // Multi-segment path: break the line whenever we hit a null so the + // chart shows gaps rather than a straight fall to zero + spike back. + const pathFor = (f: HlHistoryFrontend): string => { + const parts: string[] = []; + let inSegment = false; + for (const p of seriesOf(f)) { + if (p.v === null) { + inSegment = false; + continue; + } + const cmd = inSegment ? "L" : "M"; + parts.push(`${cmd} ${xFor(p.t).toFixed(1)} ${yFor(p.v).toFixed(1)}`); + inSegment = true; + } + return parts.join(" "); + }; + + const ticks = [0, 0.25, 0.5, 0.75, 1]; + const monthTicks = useMemo( + () => buildMonthTicks(tRange.tMin, tRange.tMax), + [tRange.tMin, tRange.tMax], + ); + + const svgRef = useRef(null); + const [hoverT, setHoverT] = useState(null); + + const onMove: React.PointerEventHandler = (e) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const xRatio = (e.clientX - rect.left) / rect.width; + const px = xRatio * W; + if (px < PAD_L || px > W - PAD_R) { + setHoverT(null); + return; + } + const span = tRange.tMax - tRange.tMin; + const t = tRange.tMin + ((px - PAD_L) / plotW) * span; + setHoverT(t); + }; + + // Snap hover to nearest sample per frontend for the tooltip readout. + const hoverRows = useMemo(() => { + if (hoverT === null) return null; + const rows: { slug: string; name: string; color: string; v: number | null }[] = []; + for (let i = 0; i < frontends.length; i++) { + const f = frontends[i]; + const s = seriesOf(f); + if (s.length === 0) { + rows.push({ slug: f.slug, name: f.name, color: COLORS[i % COLORS.length], v: null }); + continue; + } + let best = s[0]; + let bestDist = Math.abs(best.t - hoverT); + for (const p of s) { + const d = Math.abs(p.t - hoverT); + if (d < bestDist) { + bestDist = d; + best = p; + } + } + rows.push({ + slug: f.slug, + name: f.name, + color: COLORS[i % COLORS.length], + v: best.v, + }); + } + rows.sort((a, b) => (b.v ?? -1) - (a.v ?? -1)); + return rows; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hoverT, frontends, metric]); + + const hoverX = hoverT !== null ? xFor(hoverT) : null; + const hoverDate = hoverT !== null ? formatDate(hoverT) : null; + + return ( +
+ setHoverT(null)} + > + {ticks.map((t) => { + const y = PAD_T + plotH * (1 - t); + const v = yMax * t; + return ( + + + + {fmtUSDShort(v)} + + + ); + })} + + {monthTicks.map((mt) => { + const x = xFor(mt.t); + if (x < PAD_L - 1 || x > W - PAD_R + 1) return null; + return ( + + + + {mt.label} + + + ); + })} + + {frontends.map((f, i) => { + const color = COLORS[i % COLORS.length]; + const dimmed = pinnedSlug !== null && pinnedSlug !== f.slug; + return ( + + ); + })} + + {hoverX !== null && ( + + )} + + + {hoverRows && hoverDate && hoverX !== null && ( + + )} +
+ ); +} + +function Tooltip({ + rows, + date, + xFrac, +}: { + rows: { slug: string; name: string; color: string; v: number | null }[]; + date: string; + xFrac: number; +}) { + const left = Math.max(4, Math.min(96, xFrac * 100)); + const flipX = left > 55; + return ( +
+

+ {date} +

+
+ {rows.slice(0, 10).map((r) => ( +
+ + + {r.name} + + + {r.v === null ? "—" : fmtUSDShort(r.v)} + +
+ ))} +
+
+ ); +} + +function niceMax(v: number): number { + if (v <= 0) return 1; + const exp = Math.pow(10, Math.floor(Math.log10(v))); + const f = v / exp; + if (f <= 1) return exp; + if (f <= 2) return 2 * exp; + if (f <= 5) return 5 * exp; + return 10 * exp; +} + +function fmtUSDShort(v: number): string { + if (!Number.isFinite(v) || v === 0) return "$0"; + const abs = Math.abs(v); + if (abs >= 1_000_000_000) return `$${(v / 1_000_000_000).toFixed(1)}B`; + if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`; + if (abs >= 1_000) return `$${(v / 1_000).toFixed(0)}K`; + return `$${v.toFixed(0)}`; +} + +function formatDate(t: number): string { + const d = new Date(t); + return d.toISOString().slice(0, 10); +} + +/** First-of-month labels between two epoch-ms bounds. Keeps the count + * bounded (~12 labels) so the axis never crowds. */ +function buildMonthTicks( + tMinMs: number, + tMaxMs: number, +): { t: number; label: string }[] { + const out: { t: number; label: string }[] = []; + const start = new Date(tMinMs); + const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); + const MONTH_NAMES = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + while (cursor.getTime() <= tMaxMs) { + const t = cursor.getTime(); + if (t >= tMinMs) { + const label = + cursor.getUTCMonth() === 0 + ? `${MONTH_NAMES[0]} ${cursor.getUTCFullYear() % 100}` + : MONTH_NAMES[cursor.getUTCMonth()]; + out.push({ t, label }); + } + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + return out; +} diff --git a/src/lib/hl-builder-stats.ts b/src/lib/hl-builder-stats.ts index 4f1ef6c39..4f67db5f7 100644 --- a/src/lib/hl-builder-stats.ts +++ b/src/lib/hl-builder-stats.ts @@ -251,6 +251,31 @@ export type HlHip3Summary = { * misaligned payload. The cohort-snapshot module appends its own `:v1`. */ const HL_FRONTENDS_KEY = "hl-frontends"; const HL_HIP3_KEY = "hl-hip3"; +const HL_HISTORY_KEY = "hl-history"; + +/** One evenly-spaced point on a rolling-window history series. `v = null` + * means the underlying gauge had no sample at that timestamp (harness + * gap, pre-mainnet epoch, ...) — kept as a sentinel so a range's shape + * is stable across frontends and the chart can draw a gap instead of + * interpolating through zero. */ +export type HlHistoryPoint = { t: number; v: number | null }; +export type HlHistoryFrontend = { + slug: string; + name: string; + /** Rolling 30d USD builder-fee revenue, daily-stepped over ~12 months. */ + fees30d: HlHistoryPoint[]; + /** Rolling 30d USD notional volume, daily-stepped over ~12 months. */ + volume30d: HlHistoryPoint[]; +}; +export type HlHistorySummary = { + /** Range fetched in ms (365d). */ + windowMs: number; + /** Step between points in seconds (86400 = 1 day). */ + step: number; + frontends: HlHistoryFrontend[]; + /** Unix seconds when the summary was assembled. */ + asOf: number; +}; /** * Fetch a leaderboard-ready slice of every tracked HL builder, in 4 @@ -553,12 +578,186 @@ export async function fetchHlHip3Cohort(): Promise { return fetchHlHip3CohortCached(); } +/** + * Uncached fetch of the 12-month rolling fees + volume series for the + * top ~10 Hyperliquid frontends by current fees_30d. Two range queries + * per frontend (fees + volume) daily-stepped over 365d. Parallelised + * across frontends; the Prom client's global semaphore caps the burst. + * + * Ranks by an instant vector on `hl_frontend_fees_usd_30d_v2` first so + * we only pay for the range queries on frontends that actually made the + * cut, and the leaderboard on the chart stays in sync with the /hyperliquid + * cohort ordering. Returns null when Prom is unreachable so the reader + * can fall through to whatever snapshot Upstash has instead of poisoning + * the ISR cache with an empty chart. + * + * Exported so the worker can call the uncached Prom path directly before + * parking the result in Upstash; the public reader (fetchHlHistory) goes + * through the snapshot layer + unstable_cache below. + */ +export async function fetchHlHistoryFresh(): Promise { + const url = promUrl(); + if (!url) return null; + let prom: Prometheus; + try { + prom = new Prometheus(url); + } catch { + return null; + } + + const specs = await getSpecs(); + const hl = specs.find((s) => s.slug === "hyperliquid-frontends"); + const providers = hl?.providers ?? []; + const nameBySlug = new Map(providers.map((p) => [p.slug, p.name])); + + const currentFees = await queryVector(prom, `hl_frontend_fees_usd_30d_v2`); + if (!currentFees || currentFees.length === 0) return null; + + const sorted = currentFees + .filter((r) => Number.isFinite(r.value) && r.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 10); + + const end = Math.floor(Date.now() / 1000); + const start = end - 365 * 86400; + const step = 86400; + + const frontends = await Promise.all( + sorted.map(async (row) => { + const frontend = row.labels.builder; + if (!frontend) return null; + const [feesRange, volumeRange] = await Promise.all([ + queryRange( + prom, + `hl_frontend_fees_usd_30d_v2{builder="${frontend}"}`, + start, + end, + step, + ), + queryRange( + prom, + `hl_frontend_volume_usd_30d_v2{builder="${frontend}"}`, + start, + end, + step, + ), + ]); + if (!feesRange && !volumeRange) return null; + return { + slug: frontend, + name: nameBySlug.get(frontend) ?? frontend, + fees30d: feesRange ?? [], + volume30d: volumeRange ?? [], + }; + }), + ); + + const filtered = frontends.filter( + (f): f is HlHistoryFrontend => f !== null, + ); + if (filtered.length === 0) return null; + + return { + windowMs: 365 * 86400 * 1000, + step, + frontends: filtered, + asOf: Math.floor(Date.now() / 1000), + }; +} + +/** Snapshot-first reader for the 12-month history blob. Same Upstash + * → live Prom + writeback → null protocol as the two cohort readers. + * The historical data changes slowly (daily stepped), so the + * unstable_cache TTL is 1h rather than 60 s — no need to fan out + * 20 range queries against Prom on every ISR cycle. */ +async function fetchHlHistoryRaw(): Promise { + const snapshot = await readCohortSnapshot(HL_HISTORY_KEY); + if (snapshot) return snapshot.data; + const fresh = await fetchHlHistoryFresh(); + if (fresh) { + try { + await writeCohortSnapshot(HL_HISTORY_KEY, fresh); + } catch (err) { + console.warn( + `hl-history writeback failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return fresh; +} + +const fetchHlHistoryCached = unstable_cache( + fetchHlHistoryRaw, + ["hl-history-v1"], + { revalidate: 3600, tags: ["hl-cohort", "hl-history"] }, +); + +export async function fetchHlHistory(): Promise { + return fetchHlHistoryCached(); +} + /** * Tiny vector helper. Returns `[{ labels, value }]` from an instant * vector query, or empty on error/empty result. Kept local to this * module because Sprint 2 is the only consumer; the broader Prometheus * client only needs scalars. */ +/** Range query helper: returns evenly-stepped `{ t, v }` points for a + * single-series PromQL selector. If Prom returns multiple series (which + * shouldn't happen when the selector pins one label value) they're + * averaged per timestamp. Missing samples become `null` so the chart + * can render a gap rather than interpolating through zero. Returns null + * on network / Prom error so the caller can decide to skip the frontend. */ +async function queryRange( + prom: Prometheus, + promql: string, + startSec: number, + endSec: number, + stepSec: number, +): Promise { + try { + const res = await prom.queryRange( + promql, + new Date(startSec * 1000), + new Date(endSec * 1000), + stepSec, + ); + if (res.result.length === 0) return []; + const buckets = new Map(); + for (const series of res.result) { + for (const [ts, raw] of series.values) { + const v = Number(raw); + if (!Number.isFinite(v)) continue; + const list = buckets.get(ts) ?? []; + list.push(v); + buckets.set(ts, list); + } + } + const points: HlHistoryPoint[] = []; + for (let ts = startSec; ts <= endSec; ts += stepSec) { + const vs = buckets.get(ts); + if (!vs || vs.length === 0) { + points.push({ t: ts * 1000, v: null }); + } else { + const mean = vs.reduce((s, v) => s + v, 0) / vs.length; + points.push({ + t: ts * 1000, + v: mean === 0 ? 0 : Number(mean.toPrecision(6)), + }); + } + } + return points; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn( + `prom.queryRange failed (${reason}) for query: ${promql.slice(0, 200)}`, + ); + return null; + } +} + async function queryVector( prom: Prometheus, promql: string, diff --git a/worker/index.ts b/worker/index.ts index 4d064bd38..17955e6b3 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -47,6 +47,7 @@ import { fetchPerpCohortFresh } from "@/lib/perp-stats"; import { fetchHlCohortFresh, fetchHlHip3CohortFresh, + fetchHlHistoryFresh, } from "@/lib/hl-builder-stats"; import { fetchPmCohortFresh } from "@/lib/pm-stats"; import { fetchChainKpisFresh } from "@/lib/chain-kpis"; @@ -357,6 +358,7 @@ async function sweep(iteration: number): Promise { { key: "perp-cohort", build: () => fetchPerpCohortFresh() }, { key: "hl-frontends", build: () => fetchHlCohortFresh() }, { key: "hl-hip3", build: () => fetchHlHip3CohortFresh() }, + { key: "hl-history", build: () => fetchHlHistoryFresh() }, { key: "pm-hub", build: () => fetchPmCohortFresh() }, { key: "search-featured", build: () => buildFeaturedLeadersFromStore() }, // One blob per chain slug so a stale reading on one chain doesn't