diff --git a/src/app/api/debug/city-coverage/route.js b/src/app/api/debug/city-coverage/route.js new file mode 100644 index 0000000..0de1be6 --- /dev/null +++ b/src/app/api/debug/city-coverage/route.js @@ -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, + }); +} diff --git a/src/app/api/debug/pano/route.js b/src/app/api/debug/pano/route.js new file mode 100644 index 0000000..8587230 --- /dev/null +++ b/src/app/api/debug/pano/route.js @@ -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 }); + } +} diff --git a/src/app/debug/coverage/CoverageMap.js b/src/app/debug/coverage/CoverageMap.js new file mode 100644 index 0000000..ee23384 --- /dev/null +++ b/src/app/debug/coverage/CoverageMap.js @@ -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
; +} diff --git a/src/app/debug/coverage/page.js b/src/app/debug/coverage/page.js new file mode 100644 index 0000000..cfcfba1 --- /dev/null +++ b/src/app/debug/coverage/page.js @@ -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: () => ( +{selected.id}
++ {selected.lat.toFixed(5)}, {selected.lng.toFixed(5)} +
+Bbox Visualization & Mapillary Testing
+ + Panorama coverage map → +