mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-15 10:15:51 +00:00
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:
1 parent
adeebc46e2
commit
b0a090f625
6 files changed
+574
-16
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user