From 8f4618b064705b5b07e0f2b64be875263bdc41e1 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:22:04 +0200 Subject: [PATCH] feat(map): chain search, provider multi-compare, viewport-driven ranking, zoom-adaptive labels --- src/components/speedtest/rpc-map-client.tsx | 321 ++++++++++++++++---- 1 file changed, 257 insertions(+), 64 deletions(-) diff --git a/src/components/speedtest/rpc-map-client.tsx b/src/components/speedtest/rpc-map-client.tsx index fb29a0ef..0566ebd7 100644 --- a/src/components/speedtest/rpc-map-client.tsx +++ b/src/components/speedtest/rpc-map-client.tsx @@ -6,6 +6,15 @@ * median contributed latency at that location, colored on the same * green-amber-red scale as the speed test dial. Pure SVG: no tile * server, no map library, zero per-visitor server cost. + * + * Interaction model: + * - searchable chain picker (same directory as the speed test); + * - provider chips are MULTI-select: pick two or three to compare just + * them, the dots and the table re-rank on the selected subset; + * - the "In view" table follows the current zoom viewport, so zooming + * into a region turns the bottom list into that region's ranking; + * - labels densify with zoom: city names appear first, then the ms + * value of the winning provider. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -50,14 +59,16 @@ function providerName(slug: string): string { export function RpcMapClient() { const [chain, setChain] = useState("ethereum"); + const [chainQuery, setChainQuery] = useState(""); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState>(new Set()); const [hover, setHover] = useState(null); const [hoverXY, setHoverXY] = useState<[number, number]>([0, 0]); // 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); - const dragRef = useRef<{ x: number; y: number; vb: typeof vb } | null>(null); + const dragRef = useRef<{ x: number; y: number; vb: typeof vb; moved: boolean } | null>(null); useEffect(() => { let alive = true; @@ -76,7 +87,51 @@ export function RpcMapClient() { }; }, [chain]); - const cells = useMemo(() => data?.cells ?? [], [data]); + // Providers present in the current chain's data, most-sampled first. + const providersInData = useMemo(() => { + const counts = new Map(); + for (const c of data?.cells ?? []) { + for (const p of c.providers) counts.set(p.slug, (counts.get(p.slug) ?? 0) + p.samples); + } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([slug]) => slug); + }, [data]); + + // Apply the provider multi-filter: keep matching providers per cell, + // drop cells left empty, re-rank on the subset. + const cells = useMemo(() => { + const raw = data?.cells ?? []; + if (selected.size === 0) return raw; + return raw + .map((c) => { + const providers = c.providers.filter((p) => selected.has(p.slug)); + return { ...c, providers, best: providers[0]?.slug ?? "" }; + }) + .filter((c) => c.providers.length > 0); + }, [data, selected]); + + // Cells inside the current viewport drive the bottom table. + const inView = useMemo(() => { + return cells + .filter((c) => { + const [x, y] = project(c.lat, c.lon); + return x >= vb[0] && x <= vb[0] + vb[2] && y >= vb[1] && y <= vb[1] + vb[3]; + }) + .sort( + (a, b) => + b.providers.reduce((s, p) => s + p.samples, 0) - + a.providers.reduce((s, p) => s + p.samples, 0), + ); + }, [cells, vb]); + + const chainMatches = useMemo(() => { + const q = chainQuery.trim().toLowerCase(); + if (!q) return []; + return RPC_DIRECTORY.filter( + (c) => c.name.toLowerCase().includes(q) || c.slug.includes(q), + ).slice(0, 8); + }, [chainQuery]); const toSvgPoint = useCallback( (clientX: number, clientY: number): [number, number] => { @@ -97,8 +152,8 @@ export function RpcMapClient() { const factor = e.deltaY > 0 ? 1.18 : 1 / 1.18; setVb((cur) => { const [px, py] = toSvgPoint(e.clientX, e.clientY); - let w = Math.min(WORLD_W, Math.max(60, cur[2] * factor)); - let h = (w / WORLD_W) * WORLD_H; + const w = Math.min(WORLD_W, Math.max(40, cur[2] * factor)); + const h = (w / WORLD_W) * WORLD_H; let x = px - ((px - cur[0]) / cur[2]) * w; let y = py - ((py - cur[1]) / cur[3]) * h; x = Math.max(0, Math.min(WORLD_W - w, x)); @@ -109,15 +164,19 @@ export function RpcMapClient() { [toSvgPoint], ); - const onPointerDown = useCallback((e: React.PointerEvent) => { - (e.target as Element).setPointerCapture?.(e.pointerId); - dragRef.current = { x: e.clientX, y: e.clientY, vb }; - }, [vb]); + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + (e.target as Element).setPointerCapture?.(e.pointerId); + dragRef.current = { x: e.clientX, y: e.clientY, vb, moved: false }; + }, + [vb], + ); const onPointerMove = useCallback((e: React.PointerEvent) => { const d = dragRef.current; const el = svgRef.current; if (!d || !el) return; + if (Math.abs(e.clientX - d.x) + Math.abs(e.clientY - d.y) > 3) d.moved = true; const r = el.getBoundingClientRect(); const dx = ((e.clientX - d.x) / r.width) * d.vb[2]; const dy = ((e.clientY - d.y) / r.height) * d.vb[3]; @@ -131,12 +190,31 @@ export function RpcMapClient() { }, []); const zoomed = vb[2] < WORLD_W - 1; - const dotR = Math.max(3, 7 * (vb[2] / WORLD_W)); + const zoomRatio = vb[2] / WORLD_W; // 1 = world, small = deep zoom + const dotR = Math.max(1.6, 5.2 * zoomRatio); + const showCityLabels = zoomRatio < 0.35; + const showMsLabels = zoomRatio < 0.16; + + const toggleProvider = (slug: string) => { + setSelected((cur) => { + const next = new Set(cur); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + }; + + const pickChain = (slug: string) => { + setChain(slug); + setChainQuery(""); + setSelected(new Set()); + setVb([0, 0, WORLD_W, WORLD_H]); + }; return (
- {/* Chain picker */} -
+ {/* Chain picker: featured chips + search over all 87 chains */} +
{FEATURED.map((slug) => { const c = RPC_DIRECTORY.find((x) => x.slug === slug); if (!c) return null; @@ -145,7 +223,7 @@ export function RpcMapClient() { ); })} - - {zoomed && ( - - )} +
+ setChainQuery(e.target.value)} + placeholder={ + FEATURED.includes(chain) + ? `Search ${RPC_DIRECTORY.length} chains…` + : `${RPC_DIRECTORY.find((c) => c.slug === chain)?.name ?? chain} · search…` + } + spellCheck={false} + autoComplete="off" + className="w-full rounded-full border border-rule bg-transparent px-3 py-1.5 text-[12px] text-ink placeholder:text-ink-faint/70 focus:border-ink/50 focus:outline-none" + /> + {chainMatches.length > 0 && ( +
    + {chainMatches.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+ {/* Provider multi-filter: compare a chosen subset on the map */} + {providersInData.length > 1 && ( +
+ Compare: + {providersInData.map((slug) => { + const active = selected.has(slug); + return ( + + ); + })} + {selected.size > 0 && ( + + )} +
+ )} + {/* Map */} -
+
- + {cells.map((c) => { const [x, y] = project(c.lat, c.lon); const best = c.providers[0]; if (!best) return null; const n = c.providers.reduce((s, p) => s + p.samples, 0); + const r = dotR + Math.min(2.5, Math.log2(1 + n) * zoomRatio * 2); + const color = latencyColor(best.p50); return ( + {/* soft halo then crisp dot: reads as data, not sticker */} + { setHover(c); - const r = svgRef.current?.getBoundingClientRect(); - if (r) setHoverXY([e.clientX - r.left, e.clientY - r.top]); + const rect = svgRef.current?.getBoundingClientRect(); + if (rect) setHoverXY([e.clientX - rect.left, e.clientY - rect.top]); }} onMouseLeave={() => setHover(null)} /> + {showCityLabels && ( + + {c.city} + + )} + {showMsLabels && ( + + {providerName(best.slug)} · {Math.round(best.p50)} ms + + )} ); })} @@ -224,7 +383,7 @@ export function RpcMapClient() { {/* Tooltip */} {hover && (
{hover.country}

- {hover.providers.slice(0, 5).map((p, i) => ( -
- {i + 1} - - {providerName(p.slug)} - - {Math.round(p.p50)} ms - -
- ))} + {(selected.size > 0 + ? hover.providers.filter((p) => selected.has(p.slug)) + : hover.providers + ) + .slice(0, 5) + .map((p, i) => ( +
+ {i + 1} + + {providerName(p.slug)} + + {Math.round(p.p50)} ms + +
+ ))}

{hover.providers.reduce((s, p) => s + p.samples, 0)} samples in this area @@ -254,6 +418,18 @@ export function RpcMapClient() {

)} + {/* Reset zoom floating control */} + {zoomed && ( + + )} + {/* Empty / loading states */} {loading && (
@@ -263,12 +439,12 @@ export function RpcMapClient() { {!loading && cells.length === 0 && (

- No community samples for this chain yet. The map fills up as - people run the speed test: every completed test adds one - anonymous point to its city. + {selected.size > 0 + ? "No samples for this provider selection here yet. Clear the filter or run a test with these providers." + : "No community samples for this chain yet. The map fills up as people run the speed test: every completed test adds one anonymous point to its city."}

- Run the first test for this chain → + Run a test for this chain →
)} @@ -295,10 +471,19 @@ export function RpcMapClient() {
- {/* Crawlable table of the busiest areas */} - {cells.length > 0 && ( + {/* Viewport-driven ranking: zoom into a region and this becomes + that region's leaderboard. */} + {inView.length > 0 && (
-

Busiest areas

+
+

+ {zoomed ? "In the area you are viewing" : "Busiest areas"} +

+ + {inView.length} area{inView.length > 1 ? "s" : ""} + {selected.size > 0 ? ` · ${selected.size} provider${selected.size > 1 ? "s" : ""} compared` : ""} + +
@@ -306,12 +491,14 @@ export function RpcMapClient() { + - {cells.slice(0, 12).map((c) => { + {inView.slice(0, 15).map((c) => { const best = c.providers[0]; + const second = c.providers[1]; return ( - +
Area Fastest provider MedianRunner-up Samples
@@ -323,9 +510,15 @@ export function RpcMapClient() { {providerName(best.slug)} + {Math.round(best.p50)} ms + {second ? `${providerName(second.slug)} · ${Math.round(second.p50)} ms` : "-"} + {c.providers.reduce((s, p) => s + p.samples, 0)}