From eeb45095659f585861c93b011d49a0261b6be6a7 Mon Sep 17 00:00:00 2001 From: Ethan Morchy Date: Tue, 11 Aug 2026 00:40:48 -0700 Subject: [PATCH] Size the LOCATE camera from typed precision and place extent --- .../__tests__/page/pageDecomposition.test.ts | 15 +++++ frontend/src/app/LocateBar.tsx | 56 ++++++++++++------- frontend/src/app/page.tsx | 2 +- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/frontend/src/__tests__/page/pageDecomposition.test.ts b/frontend/src/__tests__/page/pageDecomposition.test.ts index 70f8544..4d1d0b8 100644 --- a/frontend/src/__tests__/page/pageDecomposition.test.ts +++ b/frontend/src/__tests__/page/pageDecomposition.test.ts @@ -195,6 +195,21 @@ describe('page.tsx decomposition — no admin-session/proxy regression', () => { expect(locateBar).not.toContain('nominatim.openstreetmap.org'); }); + it('LocateBar sizes the camera from typed precision and place extent', () => { + const locateBar = readAppFile('LocateBar.tsx'); + expect(locateBar).toContain('parseCoordinateInput'); + expect(locateBar).toContain('boundsForCoordinate'); + expect(locateBar).toContain('sanitizeGeocodeBbox'); + expect(locateBar).toContain('boundsForPlaceRank'); + // The extent fields must survive the fetch mapping. + expect(locateBar).toContain('place_rank'); + }); + + it('page.tsx forwards LocateBar bounds to the map', () => { + const page = readAppFile('page.tsx'); + expect(page).toMatch(/onLocate=\{\(lat, lng, bounds\)/); + }); + it('useRegionDossier uses backend dossier APIs (no browser-direct enrichment)', () => { const hook = fs.readFileSync( path.resolve(__dirname, '../../hooks/useRegionDossier.ts'), diff --git a/frontend/src/app/LocateBar.tsx b/frontend/src/app/LocateBar.tsx index c0bddcf..2810de4 100644 --- a/frontend/src/app/LocateBar.tsx +++ b/frontend/src/app/LocateBar.tsx @@ -3,14 +3,28 @@ import { useState, useEffect, useRef } from 'react'; import { API_BASE } from '@/lib/api'; import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants'; +import { + parseCoordinateInput, + boundsForCoordinate, + sanitizeGeocodeBbox, + boundsForPlaceRank, + type Bounds, +} from '@/lib/mapZoom'; + +type LocateResult = { + label: string; + lat: number; + lng: number; + bounds?: Bounds; +}; /* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */ -export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number) => void; onOpenChange?: (open: boolean) => void }) { +export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number, bounds?: Bounds) => void; onOpenChange?: (open: boolean) => void }) { const [open, setOpen] = useState(false); useEffect(() => { onOpenChange?.(open); }, [open]); const [value, setValue] = useState(''); - const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]); + const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [searchError, setSearchError] = useState(null); const inputRef = useRef(null); @@ -36,22 +50,17 @@ export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, return () => document.removeEventListener('mousedown', handler); }, [open]); - // Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6" - const parseCoords = (s: string): { lat: number; lng: number } | null => { - const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/); - if (!m) return null; - const lat = parseFloat(m[1]), - lng = parseFloat(m[2]); - if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng }; - return null; - }; - const handleSearch = async (q: string) => { setValue(q); // Check for raw coordinates first - const coords = parseCoords(q); + const coords = parseCoordinateInput(q); if (coords) { - setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]); + setResults([{ + label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, + lat: coords.lat, + lng: coords.lng, + bounds: boundsForCoordinate(coords.lat, coords.lng, coords.decimals), + }]); return; } // Geocode with Nominatim (debounced) @@ -74,11 +83,20 @@ export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, ); if (res.ok) { const data = await res.json(); - const mapped = (data?.results || []).map( - (r: { label: string; lat: number; lng: number }) => ({ + const mapped: LocateResult[] = (data?.results || []).map( + (r: { + label: string; lat: number; lng: number; + bbox?: string[]; place_rank?: number; + }) => ({ label: r.label, lat: r.lat, lng: r.lng, + // A usable bbox frames the real thing; otherwise fall back to + // the rank's typical extent, which is the only honest answer + // for Tokyo prefecture, France and bare OSM nodes. + bounds: + sanitizeGeocodeBbox(r.bbox, r.lat, r.lng) ?? + boundsForPlaceRank(r.lat, r.lng, r.place_rank), }), ); setResults(mapped); @@ -102,8 +120,8 @@ export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, }, NOMINATIM_DEBOUNCE_MS); }; - const handleSelect = (r: { lat: number; lng: number }) => { - onLocate(r.lat, r.lng); + const handleSelect = (r: LocateResult) => { + onLocate(r.lat, r.lng, r.bounds); setOpen(false); setValue(''); setResults([]); @@ -203,7 +221,7 @@ export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number,
{results.map((r, i) => (