diff --git a/backend/services/geocode.py b/backend/services/geocode.py index 204fdb0..234db64 100644 --- a/backend/services/geocode.py +++ b/backend/services/geocode.py @@ -171,7 +171,7 @@ def search_geocode(query: str, limit: int = 5, local_only: bool = False) -> List if not q: return [] limit = max(1, min(int(limit or 5), 10)) - key = f"search:{q.lower()}:{limit}:{int(local_only)}" + key = f"search2:{q.lower()}:{limit}:{int(local_only)}" cached = _get_cache(key) if cached is not None: return cached @@ -180,7 +180,7 @@ def search_geocode(query: str, limit: int = 5, local_only: bool = False) -> List _set_cache(key, results) return results - params = urlencode({"q": q, "format": "json", "limit": str(limit)}) + params = urlencode({"q": q, "format": "jsonv2", "limit": str(limit)}) url = f"https://nominatim.openstreetmap.org/search?{params}" try: res = fetch_with_curl( @@ -207,15 +207,23 @@ def search_geocode(query: str, limit: int = 5, local_only: bool = False) -> List data = res.json() or [] for item in data: try: - results.append( - { - "label": item.get("display_name"), - "lat": float(item.get("lat")), - "lng": float(item.get("lon")), - } - ) + entry = { + "label": item.get("display_name"), + "lat": float(item.get("lat")), + "lng": float(item.get("lon")), + } except (TypeError, ValueError): continue + # Extent metadata the frontend uses to size the camera. Absent + # for local_only results, so every field stays optional. + bbox = item.get("boundingbox") + if isinstance(bbox, list) and len(bbox) == 4: + entry["bbox"] = [str(value) for value in bbox] + if isinstance(item.get("place_rank"), int): + entry["place_rank"] = item["place_rank"] + if item.get("addresstype"): + entry["addresstype"] = str(item["addresstype"]) + results.append(entry) except Exception: results = [] diff --git a/backend/tests/test_geocode_api.py b/backend/tests/test_geocode_api.py index 7781f13..0461a04 100644 --- a/backend/tests/test_geocode_api.py +++ b/backend/tests/test_geocode_api.py @@ -2,7 +2,7 @@ from unittest.mock import patch def test_geocode_search_proxy(client): - with patch("main.search_geocode") as mock_search: + with patch("services.geocode.search_geocode") as mock_search: mock_search.return_value = [{"label": "Denver, CO, USA", "lat": 39.7392, "lng": -104.9903}] r = client.get("/api/geocode/search?q=denver&limit=1") assert r.status_code == 200 @@ -12,9 +12,42 @@ def test_geocode_search_proxy(client): def test_geocode_reverse_proxy(client): - with patch("main.reverse_geocode") as mock_reverse: + with patch("services.geocode.reverse_geocode") as mock_reverse: mock_reverse.return_value = {"label": "Boulder, CO, USA"} r = client.get("/api/geocode/reverse?lat=40.01499&lng=-105.27055") assert r.status_code == 200 data = r.json() assert data["label"] == "Boulder, CO, USA" + + +def test_geocode_search_passes_through_extent_fields(client): + with patch("services.geocode.search_geocode") as mock_search: + mock_search.return_value = [ + { + "label": "Monaco", + "lat": 43.7311, + "lng": 7.4197, + "bbox": ["43.5165358", "43.7519173", "7.4090279", "7.5329917"], + "place_rank": 4, + "addresstype": "country", + } + ] + r = client.get("/api/geocode/search?q=monaco&limit=1") + assert r.status_code == 200 + result = r.json()["results"][0] + assert result["bbox"] == [ + "43.5165358", + "43.7519173", + "7.4090279", + "7.5329917", + ] + assert result["place_rank"] == 4 + + +def test_geocode_search_tolerates_missing_extent_fields(client): + """local_only results and older cache entries carry no extent data.""" + with patch("services.geocode.search_geocode") as mock_search: + mock_search.return_value = [{"label": "Denver", "lat": 39.7392, "lng": -104.9903}] + r = client.get("/api/geocode/search?q=denver&limit=1") + assert r.status_code == 200 + assert "bbox" not in r.json()["results"][0] diff --git a/frontend/src/__tests__/lib/mapZoom.test.ts b/frontend/src/__tests__/lib/mapZoom.test.ts new file mode 100644 index 0000000..2bc3e2a --- /dev/null +++ b/frontend/src/__tests__/lib/mapZoom.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from 'vitest'; +import { + parseCoordinateInput, + boundsForCoordinate, + sanitizeGeocodeBbox, + boundsForPlaceRank, + clampZoom, +} from '@/lib/mapZoom'; + +describe('parseCoordinateInput', () => { + it('counts decimals as typed, not as parsed', () => { + // 78.00 and 78 are the same float but not the same claim. + expect(parseCoordinateInput('78.00, -119.00')?.decimals).toBe(2); + expect(parseCoordinateInput('78, -119')?.decimals).toBe(0); + }); + + it('takes the less precise axis so one sloppy field cannot drive framing', () => { + expect(parseCoordinateInput('78.00, -119')?.decimals).toBe(0); + }); + + it('accepts space separation and a trailing dot', () => { + expect(parseCoordinateInput('34.05 -118.24')?.decimals).toBe(2); + expect(parseCoordinateInput('78. -119.')?.decimals).toBe(0); + }); + + it('rejects out-of-range and non-coordinate input', () => { + expect(parseCoordinateInput('91, 0')).toBeNull(); + expect(parseCoordinateInput('0, 181')).toBeNull(); + expect(parseCoordinateInput('Tokyo')).toBeNull(); + }); +}); + +describe('boundsForCoordinate', () => { + it('brackets the point by half an uncertainty cell', () => { + const [w, s, e, n] = boundsForCoordinate(78, -119, 0); + expect(w).toBeCloseTo(-119.5, 6); + expect(e).toBeCloseTo(-118.5, 6); + expect(s).toBeCloseTo(77.5, 6); + expect(n).toBeCloseTo(78.5, 6); + }); + + it('shrinks by 10x per decimal place', () => { + const [w, , e] = boundsForCoordinate(34.052341, -118.243607, 6); + expect(e - w).toBeCloseTo(0.000001, 9); + }); + + it('does not produce latitudes beyond the poles', () => { + const [, s, , n] = boundsForCoordinate(90, 0, 0); + expect(n).toBeLessThanOrEqual(90); + expect(s).toBeGreaterThanOrEqual(-90); + }); +}); + +describe('sanitizeGeocodeBbox', () => { + it('accepts an honest city-sized box and converts to [w,s,e,n]', () => { + // Monaco the country, place_rank 4. The box is padded with territorial + // water and the point sits on its northern edge, but at 26km across the + // lopsidedness is too small to matter. + expect( + sanitizeGeocodeBbox( + ['43.5165358', '43.7519173', '7.4090279', '7.5329917'], + 43.7323492, + 7.4276832, + ), + ).toEqual([7.4090279, 43.5165358, 7.5329917, 43.7519173]); + }); + + it('accepts a town', () => { + // Zermatt, place_rank 16. + expect( + sanitizeGeocodeBbox( + ['45.9167499', '46.0643293', '7.5749926', '7.9086793'], + 46.0207133, + 7.7491027, + ), + ).not.toBeNull(); + }); + + it('keeps the honest box of a country far larger than its rank implies', () => { + // Canada is rank 4, the same rank as Monaco, and 4600km across. Sizing + // rules keyed off place_rank threw this away and flew to a 1000km box + // over the Northwest Territories. + expect( + sanitizeGeocodeBbox( + ['41.6765597', '83.3362128', '-141.0027500', '-52.3237664'], + 61.0666922, + -107.9917071, + ), + ).toEqual([-141.00275, 41.6765597, -52.3237664, 83.3362128]); + }); + + it('keeps the honest box of an oversized state', () => { + // Texas is rank 8 and 1300km wide; the old cap allowed 1000km. + expect( + sanitizeGeocodeBbox( + ['25.8371638', '36.5007041', '-106.6456461', '-93.5078063'], + 31.2638905, + -98.5456116, + ), + ).not.toBeNull(); + }); + + it('rebuilds an antimeridian-degraded box from its latitude axis', () => { + // Russia: Nominatim returns the whole planet in longitude. MapLibre's + // adjustAntiMeridian() reads that as a legitimate world box, so we must + // catch it — but the latitude axis survived and still sizes the country. + const bounds = sanitizeGeocodeBbox( + ['41.1850968', '82.0586232', '-180.0000000', '180.0000000'], + 64.6863136, + 97.7453061, + ); + expect(bounds).not.toBeNull(); + const [west, south, east, north] = bounds!; + // The latitude axis is real data and survives untouched. Re-deriving it + // from the result's own point pushes north past 85, where Mercator's + // stretch alone drags the zoom down to the floor. + expect([south, north]).toEqual([41.1850968, 82.0586232]); + expect(east - west).toBeLessThan(180); + expect(west).toBeLessThan(97.7453061); + expect(east).toBeGreaterThan(97.7453061); + }); + + it('caps a latitude axis that overseas territories also smeared', () => { + // The United States reaches 14S at American Samoa and 71N at Point + // Barrow: 9600km of latitude for a 4500km country. Uncapped, the camera + // frames a hemisphere. + const [, south, , north] = sanitizeGeocodeBbox( + ['-14.7608358', '71.5889534', '-180.0000000', '180.0000000'], + 39.7837304, + -100.4458825, + )!; + expect(north - south).toBeLessThan(50); + expect(south).toBeLessThan(39.7837304); + expect(north).toBeGreaterThan(39.7837304); + }); + + it('rejects a box smeared by overseas territories', () => { + // France: Kerguelen to French Polynesia, 350 deg of longitude. Never + // quite +/-180, so the dateline check alone would miss it. + expect( + sanitizeGeocodeBbox( + ['-50.2187169', '51.3055721', '-178.3873749', '172.3057152'], + 46.603354, + 1.8883335, + ), + ).toBeNull(); + }); + + it('rejects an island-chain prefecture', () => { + // "Tokyo" resolves to Tokyo Metropolis (rank 8), whose bbox reaches + // 1800km south to the Ogasawara Islands, leaving the city itself 1.4% + // from the northern edge. The box is roughly square, so an aspect-ratio + // test does not catch it. + expect( + sanitizeGeocodeBbox( + ['20.2145811', '35.8984245', '135.8536855', '154.2055410'], + 35.6768601, + 139.7638947, + ), + ).toBeNull(); + }); + + it('rejects a padded node box', () => { + // Mount Everest is an OSM node; Nominatim pads it to +/-0.00005 deg. + // Not zero-area, so a degenerate-bounds guard never fires. + expect( + sanitizeGeocodeBbox( + ['27.9880114', '27.9881114', '86.9251600', '86.9252600'], + 27.9880614, + 86.92521, + ), + ).toBeNull(); + }); + + it('rejects malformed input', () => { + expect(sanitizeGeocodeBbox(undefined, 0, 0)).toBeNull(); + expect(sanitizeGeocodeBbox(['1', '2'], 0, 0)).toBeNull(); + expect(sanitizeGeocodeBbox(['a', 'b', 'c', 'd'], 0, 0)).toBeNull(); + expect(sanitizeGeocodeBbox(['1', '2', '3', '4'], NaN, 0)).toBeNull(); + }); +}); + +describe('boundsForPlaceRank', () => { + it('sizes a country far wider than a building', () => { + const [cw, , ce] = boundsForPlaceRank(48.85, 2.35, 4); + const [bw, , be] = boundsForPlaceRank(48.85, 2.35, 30); + expect(ce - cw).toBeGreaterThan((be - bw) * 100); + }); + + it('widens longitude at high latitude to keep the box roughly square', () => { + const [ew, , ee] = boundsForPlaceRank(0, 0, 16); + const [aw, , ae] = boundsForPlaceRank(78, 0, 16); + expect(ae - aw).toBeGreaterThan(ee - ew); + }); + + it('falls back to a city-sized box when rank is unknown', () => { + expect(boundsForPlaceRank(39.7, -104.9, undefined)).toEqual( + boundsForPlaceRank(39.7, -104.9, 15), + ); + }); +}); + +describe('clampZoom', () => { + it('holds the camera inside the usable range', () => { + expect(clampZoom(22)).toBe(17); + expect(clampZoom(0)).toBe(2); + expect(clampZoom(11.4)).toBe(11.4); + }); +}); diff --git a/frontend/src/__tests__/map/maplibreCameraBounds.test.ts b/frontend/src/__tests__/map/maplibreCameraBounds.test.ts new file mode 100644 index 0000000..c26c1c6 --- /dev/null +++ b/frontend/src/__tests__/map/maplibreCameraBounds.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const viewer = readFileSync( + join(process.cwd(), 'src/components/MaplibreViewer.tsx'), + 'utf8', +); + +describe('MaplibreViewer bounds camera', () => { + it('derives zoom from bounds via cameraForBounds', () => { + expect(viewer).toContain('cameraForBounds'); + }); + + it('caps the fit, because a tiny bounds otherwise flies to maxZoom 22', () => { + expect(viewer).toMatch(/maxZoom:\s*ZOOM_MAX/); + }); + + it('handles cameraForBounds returning undefined on oversized padding', () => { + expect(viewer).toContain('ZOOM_FALLBACK'); + }); + + it('leaves the agent fly-to path untouched', () => { + expect(viewer).toContain('zoom: flyToLocation.zoom ?? 8,'); + }); +}); 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) => (