From d35b246859ec553d5cc760fa731bfc4371e7893c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:37:18 +0200 Subject: [PATCH] feat(map): near-me geolocation and droppable pin with nearest-area results --- src/app/api/speedtest/whereami/route.ts | 30 ++++ src/components/speedtest/rpc-map-client.tsx | 171 ++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/app/api/speedtest/whereami/route.ts diff --git a/src/app/api/speedtest/whereami/route.ts b/src/app/api/speedtest/whereami/route.ts new file mode 100644 index 00000000..cc062d7e --- /dev/null +++ b/src/app/api/speedtest/whereami/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +/** + * IP-level position of the requester, from Vercel's edge geo headers. + * Powers the "Near me" button on /rpc-map without the browser + * geolocation permission prompt. Nothing is stored; the response is + * private and uncacheable by design. + */ +export async function GET(req: NextRequest) { + const h = req.headers; + let lat = parseFloat(h.get("x-vercel-ip-latitude") ?? ""); + let lon = parseFloat(h.get("x-vercel-ip-longitude") ?? ""); + let city = decodeURIComponent(h.get("x-vercel-ip-city") ?? ""); + let country = h.get("x-vercel-ip-country") ?? ""; + if ((Number.isNaN(lat) || Number.isNaN(lon)) && process.env.NODE_ENV !== "production") { + lat = 48.86; + lon = 2.35; + city = "Paris"; + country = "FR"; + } + if (Number.isNaN(lat) || Number.isNaN(lon)) { + return NextResponse.json({ ok: false }, { status: 202 }); + } + return NextResponse.json( + { ok: true, lat, lon, city, country }, + { headers: { "cache-control": "private, no-store" } }, + ); +} diff --git a/src/components/speedtest/rpc-map-client.tsx b/src/components/speedtest/rpc-map-client.tsx index 62ee662c..96feb5ed 100644 --- a/src/components/speedtest/rpc-map-client.tsx +++ b/src/components/speedtest/rpc-map-client.tsx @@ -48,6 +48,20 @@ type CellDetail = { }[]; }; +function unproject(x: number, y: number): { lat: number; lon: number } { + return { lon: (x / WORLD_W) * 360 - 180, lat: 90 - (y / WORLD_H) * 180 }; +} + +function haversineKm(aLat: number, aLon: number, bLat: number, bLon: number): number { + const R = 6371; + const dLat = ((bLat - aLat) * Math.PI) / 180; + const dLon = ((bLon - aLon) * Math.PI) / 180; + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos((aLat * Math.PI) / 180) * Math.cos((bLat * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(s)); +} + function timeAgo(ts: number | null): string { if (ts == null) return "recently"; const s = Math.max(0, Math.floor(Date.now() / 1000) - ts); @@ -90,6 +104,8 @@ export function RpcMapClient() { const [selectedGh, setSelectedGh] = useState(null); const [cellDetail, setCellDetail] = useState(null); const [cellLoading, setCellLoading] = useState(false); + const [pin, setPin] = useState<{ lat: number; lon: number; label: string } | null>(null); + const [locating, setLocating] = 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); @@ -238,6 +254,41 @@ export function RpcMapClient() { setCellDetail(null); }; + // Nearest measured areas to the pin, closest first. + const nearest = useMemo(() => { + if (!pin) return []; + return cells + .map((c) => ({ cell: c, km: haversineKm(pin.lat, pin.lon, c.lat, c.lon) })) + .sort((a, b) => a.km - b.km) + .slice(0, 3); + }, [pin, cells]); + + const zoomTo = useCallback((lat: number, lon: number, widthFrac = 0.18) => { + const [x, y] = project(lat, lon); + const w = WORLD_W * widthFrac; + const h = (w / WORLD_W) * WORLD_H; + setVb([ + Math.max(0, Math.min(WORLD_W - w, x - w / 2)), + Math.max(0, Math.min(WORLD_H - h, y - h / 2)), + w, + h, + ]); + }, []); + + const locateMe = useCallback(() => { + setLocating(true); + fetch("/api/speedtest/whereami") + .then((r) => r.json()) + .then((d) => { + if (d?.ok) { + setPin({ lat: d.lat, lon: d.lon, label: d.city ? `${d.city}, ${d.country}` : "Your location" }); + zoomTo(d.lat, d.lon); + } + }) + .catch(() => {}) + .finally(() => setLocating(false)); + }, [zoomTo]); + const openCell = useCallback( (c: MapCell) => { setSelectedGh(c.gh); @@ -362,6 +413,21 @@ export function RpcMapClient() { onPointerUp={onPointerUp} onPointerLeave={onPointerUp} > + {/* Invisible click surface: a clean click anywhere (ocean or + land) drops the pin; dots keep their own click handler. */} + { + if (dragRef.current?.moved) return; + const [x, y] = toSvgPoint(e.clientX, e.clientY); + const { lat, lon } = unproject(x, y); + setPin({ lat, lon, label: "Dropped pin" }); + }} + /> {cells.map((c) => { const [x, y] = project(c.lat, c.lon); @@ -474,6 +541,57 @@ export function RpcMapClient() { )} + {/* Pin marker rendered above the data dots */} + {pin && ( + + {(() => { + const [x, y] = project(pin.lat, pin.lon); + const s = Math.max(4, 14 * zoomRatio); + return ( + + + + + + ); + })()} + + )} + + {/* Near me + pin controls, floating on the map */} +
+ + {pin ? ( + + ) : ( + + or click the map to drop a pin + + )} +
+ {/* Reset zoom floating control */} {zoomed && ( + ); + })} + + )} + + )} + {/* Cell detail: opened by clicking a dot. Median, freshness and the retained contribution history per provider. */} {selectedGh && (