From e1d15097143d1ad0a1d42d650edbae63bc8ab326 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:30:26 +0200 Subject: [PATCH] feat(map): clickable cells with per-provider history and freshness --- src/app/api/speedtest/cell/route.ts | 101 +++++++++++++++ src/app/api/speedtest/contribute/route.ts | 5 +- src/app/api/speedtest/map/route.ts | 11 +- src/components/speedtest/rpc-map-client.tsx | 128 +++++++++++++++++++- src/lib/speedtest/geo.ts | 14 +++ 5 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 src/app/api/speedtest/cell/route.ts diff --git a/src/app/api/speedtest/cell/route.ts b/src/app/api/speedtest/cell/route.ts new file mode 100644 index 00000000..385316dc --- /dev/null +++ b/src/app/api/speedtest/cell/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { unstable_cache } from "next/cache"; +import { redisPipeline, storeConfigured } from "@/lib/materialize/store"; +import { median, monthKeys, parseLatEntry } from "@/lib/speedtest/geo"; +import { RPC_DIRECTORY } from "@/lib/speedtest/rpc-directory"; + +export const runtime = "nodejs"; + +/** + * Detail view for one map cell: the full retained contribution history + * (last 24 readings per provider over the rolling two-month window), + * with timestamps, powering the click-through panel on /rpc-map. + */ + +const KNOWN_CHAINS = new Set(RPC_DIRECTORY.map((c) => c.slug)); +const GH_RE = /^[0-9b-hj-km-np-z]{4}$/; + +async function buildCell(chain: string, gh: string) { + const [cur, prev] = monthKeys(new Date()); + const [curPairs, prevPairs, metaFlat] = (await redisPipeline([ + ["SMEMBERS", `stm:idx:${cur}:${chain}`], + ["SMEMBERS", `stm:idx:${prev}:${chain}`], + ["HGETALL", `stm:meta:${gh}`], + ])) as [string[] | null, string[] | null, string[] | Record | null]; + + const slugs = Array.from( + new Set( + [...(curPairs ?? []), ...(prevPairs ?? [])] + .filter((p) => p.startsWith(`${gh}:`)) + .map((p) => p.slice(5)), + ), + ); + let meta: Record = {}; + if (Array.isArray(metaFlat)) { + for (let j = 0; j < metaFlat.length; j += 2) meta[metaFlat[j]] = metaFlat[j + 1]; + } else if (metaFlat && typeof metaFlat === "object") { + meta = metaFlat as Record; + } + if (slugs.length === 0) return { gh, city: meta.city ?? "Unknown", country: meta.country ?? "??", providers: [] }; + + const readCmds: (string | number)[][] = []; + for (const slug of slugs) { + readCmds.push(["LRANGE", `stm:lat:${cur}:${gh}:${chain}:${slug}`, 0, -1]); + readCmds.push(["LRANGE", `stm:lat:${prev}:${gh}:${chain}:${slug}`, 0, -1]); + } + const results = await redisPipeline(readCmds); + + const providers = slugs + .map((slug, i) => { + const entries = [ + ...((results[i * 2] as string[] | null) ?? []), + ...((results[i * 2 + 1] as string[] | null) ?? []), + ] + .map((raw) => parseLatEntry(String(raw))) + .filter((e): e is { ts: number | null; p50: number } => e !== null) + // LPUSH order is newest first; keep it that way for the panel. + .slice(0, 24); + if (entries.length === 0) return null; + const values = entries.map((e) => e.p50); + const tss = entries.map((e) => e.ts).filter((t): t is number => t !== null); + return { + slug, + p50: Math.round(median(values) * 10) / 10, + samples: entries.length, + lastTs: tss.length > 0 ? Math.max(...tss) : null, + history: entries.map((e) => ({ ts: e.ts, p50: Math.round(e.p50 * 10) / 10 })), + }; + }) + .filter((p): p is NonNullable => p !== null) + .sort((a, b) => a.p50 - b.p50); + + return { gh, city: meta.city ?? "Unknown", country: meta.country ?? "??", providers }; +} + +const cachedCell = unstable_cache( + async (chain: string, gh: string) => buildCell(chain, gh), + ["speedtest-cell-v1"], + { revalidate: 120 }, +); + +export async function GET(req: NextRequest) { + const chain = req.nextUrl.searchParams.get("chain") ?? ""; + const gh = req.nextUrl.searchParams.get("gh") ?? ""; + if (!KNOWN_CHAINS.has(chain) || !GH_RE.test(gh)) { + return NextResponse.json({ error: "bad_params" }, { status: 400 }); + } + if (!storeConfigured()) { + return NextResponse.json({ gh, providers: [] }); + } + try { + const data = await cachedCell(chain, gh); + return NextResponse.json(data, { + headers: { + "cache-control": "public, s-maxage=120, stale-while-revalidate=600", + "access-control-allow-origin": "*", + }, + }); + } catch { + return NextResponse.json({ gh, providers: [] }); + } +} diff --git a/src/app/api/speedtest/contribute/route.ts b/src/app/api/speedtest/contribute/route.ts index d1305555..af7a5517 100644 --- a/src/app/api/speedtest/contribute/route.ts +++ b/src/app/api/speedtest/contribute/route.ts @@ -125,7 +125,10 @@ export async function POST(req: NextRequest) { const pair = `${gh}:${e.slug}`; const latKey = `stm:lat:${ym}:${gh}:${parsed.chain}:${e.slug}`; cmds.push(["SADD", `stm:idx:${ym}:${parsed.chain}`, pair]); - cmds.push(["LPUSH", latKey, e.p50]); + // "unixSeconds:p50" so the map can show when each area was last + // measured and render per-provider contribution history. The map + // reader also accepts bare legacy numbers. + cmds.push(["LPUSH", latKey, `${Math.floor(Date.now() / 1000)}:${e.p50}`]); cmds.push(["LTRIM", latKey, 0, RESERVOIR - 1]); cmds.push(["EXPIRE", latKey, TTL_SEC]); } diff --git a/src/app/api/speedtest/map/route.ts b/src/app/api/speedtest/map/route.ts index b65a0a81..3d15edd9 100644 --- a/src/app/api/speedtest/map/route.ts +++ b/src/app/api/speedtest/map/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { unstable_cache } from "next/cache"; import { redisPipeline, storeConfigured } from "@/lib/materialize/store"; -import { geohashCenter, median, monthKeys } from "@/lib/speedtest/geo"; +import { geohashCenter, median, monthKeys, parseLatEntry } from "@/lib/speedtest/geo"; import { RPC_DIRECTORY } from "@/lib/speedtest/rpc-directory"; export const runtime = "nodejs"; @@ -21,7 +21,7 @@ type CellOut = { lon: number; city: string; country: string; - providers: { slug: string; p50: number; samples: number }[]; + providers: { slug: string; p50: number; samples: number; lastTs: number | null }[]; best: string; }; @@ -68,7 +68,10 @@ async function buildMap(chain: string): Promise<{ cells: CellOut[]; total: numbe const slug = pair.slice(5); const curList = (results[i * 2] as string[] | null) ?? []; const prevList = (results[i * 2 + 1] as string[] | null) ?? []; - const values = [...curList, ...prevList].map(Number).filter((v) => Number.isFinite(v)); + const entries = [...curList, ...prevList] + .map((raw) => parseLatEntry(String(raw))) + .filter((e): e is { ts: number | null; p50: number } => e !== null); + const values = entries.map((e) => e.p50); if (values.length === 0) continue; let cell = byCell.get(gh); if (!cell) { @@ -85,10 +88,12 @@ async function buildMap(chain: string): Promise<{ cells: CellOut[]; total: numbe }; byCell.set(gh, cell); } + const tss = entries.map((e) => e.ts).filter((t): t is number => t !== null); cell.providers.push({ slug, p50: Math.round(median(values) * 10) / 10, samples: values.length, + lastTs: tss.length > 0 ? Math.max(...tss) : null, }); } const cells = Array.from(byCell.values()); diff --git a/src/components/speedtest/rpc-map-client.tsx b/src/components/speedtest/rpc-map-client.tsx index 0566ebd7..62ee662c 100644 --- a/src/components/speedtest/rpc-map-client.tsx +++ b/src/components/speedtest/rpc-map-client.tsx @@ -29,12 +29,34 @@ type MapCell = { lon: number; city: string; country: string; - providers: { slug: string; p50: number; samples: number }[]; + providers: { slug: string; p50: number; samples: number; lastTs: number | null }[]; best: string; }; type MapData = { chain: string; cells: MapCell[]; total: number }; +type CellDetail = { + gh: string; + city: string; + country: string; + providers: { + slug: string; + p50: number; + samples: number; + lastTs: number | null; + history: { ts: number | null; p50: number }[]; + }[]; +}; + +function timeAgo(ts: number | null): string { + if (ts == null) return "recently"; + const s = Math.max(0, Math.floor(Date.now() / 1000) - ts); + if (s < 90) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86400) return `${Math.floor(s / 3600)} h ago`; + return `${Math.floor(s / 86400)} d ago`; +} + const FEATURED = ["ethereum", "base", "arbitrum", "bnb", "polygon", "optimism"]; function project(lat: number, lon: number): [number, number] { @@ -65,6 +87,9 @@ export function RpcMapClient() { const [selected, setSelected] = useState>(new Set()); const [hover, setHover] = useState(null); const [hoverXY, setHoverXY] = useState<[number, number]>([0, 0]); + const [selectedGh, setSelectedGh] = useState(null); + const [cellDetail, setCellDetail] = useState(null); + const [cellLoading, setCellLoading] = useState(false); // viewBox as [x, y, w, h]; wheel zooms toward the cursor, drag pans. const [vb, setVb] = useState<[number, number, number, number]>([0, 0, WORLD_W, WORLD_H]); const svgRef = useRef(null); @@ -209,8 +234,26 @@ export function RpcMapClient() { setChainQuery(""); setSelected(new Set()); setVb([0, 0, WORLD_W, WORLD_H]); + setSelectedGh(null); + setCellDetail(null); }; + const openCell = useCallback( + (c: MapCell) => { + setSelectedGh(c.gh); + setCellLoading(true); + setCellDetail(null); + fetch(`/api/speedtest/cell?chain=${chain}&gh=${c.gh}`) + .then((r) => r.json()) + .then((d) => { + setCellDetail(d); + setCellLoading(false); + }) + .catch(() => setCellLoading(false)); + }, + [chain], + ); + return (
{/* Chain picker: featured chips + search over all 87 chains */} @@ -338,6 +381,9 @@ export function RpcMapClient() { {/* soft halo then crisp dot: reads as data, not sticker */} + {selectedGh === c.gh && ( + + )} setHover(null)} + onClick={() => { + if (!dragRef.current?.moved) openCell(c); + }} /> {showCityLabels && (

- {hover.providers.reduce((s, p) => s + p.samples, 0)} samples in this area + {hover.providers.reduce((s, p) => s + p.samples, 0)} samples · last measured{" "} + {timeAgo( + hover.providers.reduce( + (m, p) => (p.lastTs != null && (m == null || p.lastTs > m) ? p.lastTs : m), + null, + ), + )}{" "} + · click for history

)} @@ -450,6 +506,74 @@ export function RpcMapClient() { )} + {/* Cell detail: opened by clicking a dot. Median, freshness and + the retained contribution history per provider. */} + {selectedGh && ( +
+
+

+ {cellDetail ? `${cellDetail.city}, ${cellDetail.country}` : "Loading area…"} + cell {selectedGh} +

+ +
+ {cellLoading &&

Loading history…

} + {cellDetail && cellDetail.providers.length === 0 && !cellLoading && ( +

No retained history for this area yet.

+ )} +
+ {(cellDetail?.providers ?? []) + .filter((p) => selected.size === 0 || selected.has(p.slug)) + .map((p) => { + const chrono = [...p.history].reverse(); // oldest → newest + const maxV = Math.max(...chrono.map((h) => h.p50), 1); + return ( +
+
+ + + {providerName(p.slug)} + + + {Math.round(p.p50)} ms + +
+
+ {chrono.map((h, i) => ( + + ))} +
+

+ median of {p.samples} contribution{p.samples > 1 ? "s" : ""} · last measured {timeAgo(p.lastTs)} +

+
+ ); + })} +
+

+ Bars are the last {Math.min(24, Math.max(...(cellDetail?.providers ?? [{ samples: 0 }]).map((p) => p.samples)))} contributed medians for this area, oldest to newest. Hover a bar for the exact reading and time. +

+
+ )} + {/* Legend */}
Median latency: diff --git a/src/lib/speedtest/geo.ts b/src/lib/speedtest/geo.ts index dd8c1643..25566260 100644 --- a/src/lib/speedtest/geo.ts +++ b/src/lib/speedtest/geo.ts @@ -84,6 +84,20 @@ export function median(values: number[]): number { return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; } +/** Parse a stored latency entry: "unixSeconds:p50" (current format) or + * a bare legacy number (ts null). Invalid entries return null. */ +export function parseLatEntry(raw: string): { ts: number | null; p50: number } | null { + const idx = raw.indexOf(":"); + if (idx === -1) { + const v = Number(raw); + return Number.isFinite(v) ? { ts: null, p50: v } : null; + } + const ts = Number(raw.slice(0, idx)); + const v = Number(raw.slice(idx + 1)); + if (!Number.isFinite(v)) return null; + return { ts: Number.isFinite(ts) ? ts : null, p50: v }; +} + /** Current + previous month keys ("2026-09"), the map's rolling window. */ export function monthKeys(now: Date): [string, string] { const y = now.getUTCFullYear();