feat(debug): add a panorama coverage map

Shows what the index actually holds: pick a city, see its outline and a
dot for every panorama, and click near one to open it in the viewer. It
answers the question the counts alone could not, which is whether
coverage spans a city or clusters on a few streets.

Points come back for the current viewport only. Ha Noi holds 225,985 of
them, which is far more than a map can draw and 13.8MB to send, so a
zoomed-out view gets an even sample and says so, and zooming in yields
every panorama in view.

Selection is resolved against the point list on a map click rather than
through per-marker handlers. Canvas hit-testing did not fire for these
markers, and requiring a pixel-perfect hit on a 2.5px dot would be
unpleasant even where it works; the nearest point within a small radius
wins instead.

The outline is sent only on the first request for a city. Returning it
with every viewport query gave the client a new object each time, which
made the map refit to the whole city, cancel whatever the user had zoomed
into, and trigger another fetch: it never settled.

Thumbnail URLs are signed and short-lived, so the index stores ids and
the picture is resolved when a point is opened. That lookup is the same
one the game uses, lifted out of fetchCityPanorama rather than copied.
This commit is contained in:
tiennm99 committed 2026-08-30 13:35:39 +07:00
1 parent adeebc46e2
commit b0a090f625
6 files changed
+574 -16

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { CITY_BOUNDARIES } from '../../../../data/boundaries/index.js';
import { PANO_INDEXES } from '../../../../data/panos/index.js';
// Serves a city's outline and its panorama locations for the coverage debug
// page. Points come back for the requested viewport only: Ha Noi holds 225,985
// of them, which is 13.8MB of JSON and far more than a map can draw, so the
// whole index is never sent at once.
const DEFAULT_LIMIT = 12000;
const MAX_LIMIT = 40000;
/**
* Take an evenly spaced sample without clustering it in one corner.
* @param {Object[]} items Source list, spatially sorted by the build.
* @param {number} limit Maximum to return.
* @returns {Object[]} Sampled list.
*/
function sampleEvenly(items, limit) {
if (items.length <= limit) return items;
// A fixed stride over the list rather than a random draw: the index is sorted
// by latitude, so every stride covers the whole city.
const stride = items.length / limit;
const out = [];
for (let i = 0; out.length < limit && Math.floor(i) < items.length; i += stride) {
out.push(items[Math.floor(i)]);
}
return out;
}
export async function GET(request) {
const { searchParams } = new URL(request.url);
const code = (searchParams.get('city') || '').toUpperCase();
const boundary = CITY_BOUNDARIES[code];
const index = PANO_INDEXES[code];
if (!boundary || !index) {
return NextResponse.json(
{
success: false,
error: `Unknown city: ${code || '(none)'}`,
available: Object.keys(PANO_INDEXES),
},
{ status: 400 }
);
}
const limit = Math.min(
MAX_LIMIT,
Math.max(1, Number(searchParams.get('limit')) || DEFAULT_LIMIT)
);
// bbox is west,south,east,north, matching the order the rest of the app uses.
const bboxParam = searchParams.get('bbox');
let inView = index.panos;
if (bboxParam) {
const [west, south, east, north] = bboxParam.split(',').map(Number);
if ([west, south, east, north].every(Number.isFinite)) {
inView = index.panos.filter(
(p) => p.lat >= south && p.lat <= north && p.lng >= west && p.lng <= east
);
}
}
const panos = sampleEvenly(inView, limit);
return NextResponse.json({
success: true,
city: { code, name: index.name, center: index.center, bbox: index.bbox },
// Only the first request for a city carries the outline. Returning it with
// every viewport query gave the client a new object each time, which made
// the map refit to the city and cancel whatever the user had zoomed into.
boundary: bboxParam ? undefined : boundary,
counts: {
total: index.panos.length,
inView: inView.length,
shown: panos.length,
// True when the viewport holds more points than were sent, so the page
// can say the dots are a sample rather than the whole picture.
sampled: panos.length < inView.length,
},
generatedAt: index.generatedAt,
panos,
});
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { fetchPanoramaById } from '../../../../lib/mapillary.js';
// Resolves a panorama id to a displayable image for the coverage debug page.
// The index only stores ids and coordinates, and thumbnail URLs are signed and
// short-lived, so they have to be fetched when a point is actually opened.
export async function GET(request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id || !/^\d+$/.test(id)) {
return NextResponse.json(
{ success: false, error: 'A numeric image id is required' },
{ status: 400 }
);
}
try {
return NextResponse.json({ success: true, pano: await fetchPanoramaById(id) });
} catch (error) {
console.error(`Debug panorama lookup failed for ${id}:`, error);
return NextResponse.json({ success: false, error: error.message }, { status: 502 });
}
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Panorama dots are drawn on a canvas layer, not as DOM markers. Twelve
// thousand DOM elements would stall the browser; a canvas renderer draws the
// same points in one pass.
const DOT_RADIUS = 2.5;
// Panning fires moveend continuously. Waiting for the map to settle turns a
// drag into one request instead of a dozen.
const MOVE_DEBOUNCE_MS = 250;
// How near a click has to land to count as picking a panorama. Selection is
// resolved against the point list rather than through per-marker click
// handlers: canvas hit-testing did not fire for these markers, and requiring a
// pixel-perfect hit on a 2.5px dot would be miserable to use anyway.
const CLICK_TOLERANCE_PX = 14;
export default function CoverageMap({
boundary,
panos,
selectedId,
onSelectPano,
onBoundsChange,
}) {
const containerRef = useRef(null);
const mapRef = useRef(null);
const rendererRef = useRef(null);
const boundaryLayerRef = useRef(null);
const dotsLayerRef = useRef(null);
const highlightRef = useRef(null);
const panosRef = useRef(panos);
const onBoundsChangeRef = useRef(onBoundsChange);
const onSelectPanoRef = useRef(onSelectPano);
panosRef.current = panos;
onBoundsChangeRef.current = onBoundsChange;
onSelectPanoRef.current = onSelectPano;
// Create the map once. Data arrives through the effects below.
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, { preferCanvas: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors',
}).addTo(map);
map.setView([16.0, 107.0], 6);
// One renderer for the life of the map. Creating it per data refresh leaked
// a canvas each time, because removing the markers leaves their renderer
// attached to the map.
rendererRef.current = L.canvas({ padding: 0.2 }).addTo(map);
let moveTimer = null;
const report = () => {
clearTimeout(moveTimer);
moveTimer = setTimeout(() => {
const b = map.getBounds();
onBoundsChangeRef.current?.([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
}, MOVE_DEBOUNCE_MS);
};
map.on('moveend', report);
const pick = (event) => {
const points = panosRef.current;
if (!points?.length || !onSelectPanoRef.current) return;
// Compare in screen space so the tolerance means the same thing at every
// zoom level.
const clicked = event.containerPoint;
let best = null;
let bestDistance = Infinity;
for (const point of points) {
const at = map.latLngToContainerPoint([point.lat, point.lng]);
const distance = clicked.distanceTo(at);
if (distance < bestDistance) {
bestDistance = distance;
best = point;
}
}
if (best && bestDistance <= CLICK_TOLERANCE_PX) onSelectPanoRef.current(best);
};
map.on('click', pick);
mapRef.current = map;
return () => {
clearTimeout(moveTimer);
map.off('moveend', report);
map.off('click', pick);
map.remove();
mapRef.current = null;
rendererRef.current = null;
highlightRef.current = null;
};
}, []);
// Draw the city outline and frame it whenever the city changes.
useEffect(() => {
const map = mapRef.current;
if (!map || !boundary) return;
boundaryLayerRef.current?.remove();
const layer = L.geoJSON(boundary, {
style: {
color: '#da251d',
weight: 2,
fillOpacity: 0.04,
dashArray: '6 4',
// The outline must not swallow clicks meant for the points beneath it.
interactive: false,
},
}).addTo(map);
boundaryLayerRef.current = layer;
map.fitBounds(layer.getBounds(), { padding: [24, 24] });
}, [boundary]);
// Replace the dots wholesale: the point set changes completely as the
// viewport moves, so there is nothing worth diffing.
useEffect(() => {
const map = mapRef.current;
const renderer = rendererRef.current;
if (!map || !renderer) return;
dotsLayerRef.current?.remove();
if (!panos?.length) {
dotsLayerRef.current = null;
return;
}
dotsLayerRef.current = L.layerGroup(
panos.map((p) =>
L.circleMarker([p.lat, p.lng], {
renderer,
radius: DOT_RADIUS,
stroke: false,
fillColor: '#2563eb',
fillOpacity: 0.65,
interactive: false,
})
)
).addTo(map);
}, [panos]);
// The selected point gets its own marker rather than a restyled dot: the dots
// are replaced on every viewport change, and a highlight tied to one of them
// would vanish the moment the map moved.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
highlightRef.current?.remove();
highlightRef.current = null;
const selected = panos?.find((p) => p.id === selectedId);
if (!selected) return;
highlightRef.current = L.circleMarker([selected.lat, selected.lng], {
radius: 9,
weight: 3,
color: '#da251d',
fillColor: '#da251d',
fillOpacity: 0.25,
interactive: false,
}).addTo(map);
}, [selectedId, panos]);
return <div ref={containerRef} className="h-full w-full rounded-lg bg-muted" />;
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import dynamic from 'next/dynamic';
import { ArrowLeft, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import PanoramaViewer from '../../components/PanoramaViewer';
import { cities } from '../../../lib/game';
const CoverageMap = dynamic(() => import('./CoverageMap'), {
ssr: false,
loading: () => (
<div
role="status"
aria-live="polite"
className="flex h-full w-full items-center justify-center rounded-lg bg-muted text-muted-foreground"
>
Loading map...
</div>
),
});
export default function CoveragePage() {
const [city, setCity] = useState(cities[0]?.code ?? 'TPHCM');
// The outline is held apart from the dots and replaced only when the city
// changes. Storing it with each response gave it a new identity on every
// pan, and the map treated that as a new city and refit itself, so no zoom
// ever survived and each refit triggered another fetch.
const [boundary, setBoundary] = useState(null);
const [panos, setPanos] = useState(null);
const [counts, setCounts] = useState(null);
const [generatedAt, setGeneratedAt] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// The point the user clicked, and the image it resolves to. Thumbnail URLs
// are signed and short-lived, so the index stores ids only and the picture is
// fetched when a point is actually opened.
const [selected, setSelected] = useState(null);
const [pano, setPano] = useState(null);
const [panoError, setPanoError] = useState(null);
const [panoLoading, setPanoLoading] = useState(false);
const panoRequestRef = useRef(0);
// Only the newest response may update the view: panning fires requests faster
// than they come back, and an older one landing last would redraw stale dots.
const requestIdRef = useRef(0);
const load = useCallback(async (cityCode, viewport) => {
const requestId = ++requestIdRef.current;
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ city: cityCode });
if (viewport) params.set('bbox', viewport.join(','));
const response = await fetch(`/api/debug/city-coverage?${params}`);
const json = await response.json();
if (requestId !== requestIdRef.current) return;
if (!json.success) {
setError(json.error || 'Request failed');
setPanos(null);
return;
}
// Present only on the first request for a city.
if (json.boundary) setBoundary(json.boundary);
setPanos(json.panos);
setCounts(json.counts);
setGeneratedAt(json.generatedAt);
} catch (fetchError) {
if (requestId === requestIdRef.current) setError(String(fetchError.message));
} finally {
if (requestId === requestIdRef.current) setLoading(false);
}
}, []);
useEffect(() => {
setBoundary(null);
setPanos(null);
setSelected(null);
setPano(null);
load(city, null);
}, [city, load]);
const handleSelectPano = useCallback(async (point) => {
const requestId = ++panoRequestRef.current;
setSelected(point);
setPano(null);
setPanoError(null);
setPanoLoading(true);
try {
const response = await fetch(`/api/debug/pano?id=${encodeURIComponent(point.id)}`);
const json = await response.json();
if (requestId !== panoRequestRef.current) return;
if (json.success) setPano(json.pano);
else setPanoError(json.error || 'Lookup failed');
} catch (fetchError) {
if (requestId === panoRequestRef.current) setPanoError(String(fetchError.message));
} finally {
if (requestId === panoRequestRef.current) setPanoLoading(false);
}
}, []);
const closePano = useCallback(() => {
// Bump the request id so a lookup still in flight cannot reopen the panel.
panoRequestRef.current++;
setSelected(null);
setPano(null);
setPanoError(null);
setPanoLoading(false);
}, []);
const handleBoundsChange = useCallback(
(nextBbox) => {
load(city, nextBbox);
},
[city, load]
);
return (
<div className="flex h-dvh flex-col vn-gradient-bg">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-card px-4 py-3">
<div className="flex items-center gap-3">
<Button asChild variant="ghost" size="sm" aria-label="Back to debug tools">
<Link href="/debug">
<ArrowLeft className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">Debug</span>
</Link>
</Button>
<h1 className="text-lg font-bold text-foreground">Panorama coverage</h1>
</div>
<div role="group" aria-label="City" className="flex flex-wrap gap-1">
{cities.map((option) => (
<Button
key={option.code}
onClick={() => setCity(option.code)}
variant={city === option.code ? 'default' : 'outline'}
size="sm"
aria-pressed={city === option.code}
>
{option.name}
</Button>
))}
</div>
</header>
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-card/60 px-4 py-2 text-sm">
{error && (
<span role="alert" className="font-medium text-destructive">
{error}
</span>
)}
{counts && (
<>
<Badge variant="secondary" className="tabular-nums">
{counts.total.toLocaleString()} in city
</Badge>
<Badge variant="secondary" className="tabular-nums">
{counts.inView.toLocaleString()} in view
</Badge>
<Badge variant="brand" className="tabular-nums">
{counts.shown.toLocaleString()} drawn
</Badge>
{counts.sampled && (
<span className="text-muted-foreground">
sampled evenly — zoom in to see every panorama
</span>
)}
</>
)}
{generatedAt && (
<span className="ml-auto text-xs text-muted-foreground">
snapshot {new Date(generatedAt).toISOString().slice(0, 10)}
</span>
)}
{loading && <span className="text-xs text-muted-foreground">loading…</span>}
</div>
<div className="grid min-h-0 flex-1 gap-3 p-3 lg:grid-cols-2">
<div className="min-h-[40dvh] lg:min-h-0">
<CoverageMap
boundary={boundary}
panos={panos}
selectedId={selected?.id}
onSelectPano={handleSelectPano}
onBoundsChange={handleBoundsChange}
/>
</div>
{selected && (
<div className="flex min-h-[40dvh] flex-col overflow-hidden rounded-lg border border-border bg-card lg:min-h-0">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{selected.id}</p>
<p className="text-xs text-muted-foreground tabular-nums">
{selected.lat.toFixed(5)}, {selected.lng.toFixed(5)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="ghost" size="sm">
<a
href={`https://www.mapillary.com/app/?pKey=${selected.id}&focus=photo`}
target="_blank"
rel="noreferrer noopener"
>
Mapillary
</a>
</Button>
<Button
onClick={closePano}
variant="ghost"
size="icon"
aria-label="Close panorama"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 bg-neutral-900">
{panoError ? (
<div role="alert" className="flex h-full items-center justify-center p-4 text-center text-sm text-destructive">
{panoError}
</div>
) : pano ? (
<PanoramaViewer key={pano.id} imageUrl={pano.url} />
) : (
<div
role="status"
aria-live="polite"
className="flex h-full items-center justify-center text-sm text-neutral-300"
>
{panoLoading ? 'Loading panorama…' : 'Select a point'}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
+6
View File
@@ -163,6 +163,12 @@ export default function DebugPage() {
<div>
<h1 className="text-3xl font-bold text-white">Debug Tool</h1>
<p className="text-white/80">Bbox Visualization & Mapillary Testing</p>
<Link
href="/debug/coverage"
className="mt-2 inline-block text-sm font-semibold text-white underline underline-offset-4 hover:opacity-80"
>
Panorama coverage map →
</Link>
</div>
<Button asChild>
<Link href="/">Back to Home</Link>
+39 -16
View File
@@ -20,6 +20,18 @@ const FIELDS = 'id,thumb_2048_url,thumb_original_url,geometry,is_pano';
const MAX_ATTEMPTS = 3;
const REQUEST_TIMEOUT_MS = 10000;
/**
* Read the access token, failing loudly when it is absent.
* @returns {string} The token.
*/
function requireAccessToken() {
const accessToken = process.env.MAPILLARY_ACCESS_TOKEN;
if (!accessToken) {
throw new Error('MAPILLARY_ACCESS_TOKEN environment variable is not set');
}
return accessToken;
}
/**
* Look up one image by id.
* @param {string} imageId Mapillary image id.
@@ -42,16 +54,34 @@ async function fetchImage(imageId, accessToken) {
return await response.json();
}
/**
* Resolve one panorama id to something the viewer can display.
* @param {string} imageId Mapillary image id.
* @returns {Promise<{id: string, url: string, isPano: boolean, lat: number, lng: number}>}
*/
export async function fetchPanoramaById(imageId) {
const image = await fetchImage(imageId, requireAccessToken());
// The original is often 4-8 MP; the 2048px derivative is the one the viewer
// should load on a phone.
const url = image.thumb_2048_url || image.thumb_original_url;
if (!url) throw new Error('image has no usable thumbnail');
return {
id: String(image.id),
url,
isPano: image.is_pano ?? true,
lat: image.geometry?.coordinates?.[1] ?? null,
lng: image.geometry?.coordinates?.[0] ?? null,
};
}
/**
* Choose a panorama for a city and resolve it to a usable image.
* @param {string} cityCode City code, e.g. 'TPHCM'.
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
*/
export async function fetchCityPanorama(cityCode) {
const accessToken = process.env.MAPILLARY_ACCESS_TOKEN;
if (!accessToken) {
throw new Error('MAPILLARY_ACCESS_TOKEN environment variable is not set');
}
requireAccessToken();
const tried = new Set();
let lastError = null;
@@ -61,21 +91,14 @@ export async function fetchCityPanorama(cityCode) {
tried.add(candidate.id);
try {
const image = await fetchImage(candidate.id, accessToken);
// The original is often 4-8 MP; the 2048px derivative is the one the
// viewer should load on a phone.
const url = image.thumb_2048_url || image.thumb_original_url;
if (!url) throw new Error('image has no usable thumbnail');
const image = await fetchPanoramaById(candidate.id);
return {
success: true,
data: {
id: String(image.id),
url,
isPano: image.is_pano ?? true,
// Trust the API's coordinates over the index's copy.
lat: image.geometry?.coordinates?.[1] ?? candidate.lat,
lng: image.geometry?.coordinates?.[0] ?? candidate.lng,
...image,
// Trust the API's coordinates, falling back to the index's copy.
lat: image.lat ?? candidate.lat,
lng: image.lng ?? candidate.lng,
},
};
} catch (error) {