Merge pull request #493 from emorchy/feature/locate-auto-zoom

Feature/locate auto zoom
This commit is contained in:
Shadowbroker
2026-08-12 15:21:56 -06:00
committed by GitHub
10 changed files with 617 additions and 39 deletions
+17 -9
View File
@@ -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 = []
+35 -2
View File
@@ -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]
+209
View File
@@ -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);
});
});
@@ -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,');
});
});
@@ -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'),
+37 -19
View File
@@ -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<LocateResult[]>([]);
const [loading, setLoading] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(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,
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
{results.map((r, i) => (
<button
key={i}
key={`${r.label}-${r.lat}-${r.lng}`}
onClick={() => handleSelect(r)}
className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2"
>
+2 -1
View File
@@ -437,6 +437,7 @@ export default function Dashboard() {
lat: number;
lng: number;
zoom?: number;
bounds?: [number, number, number, number];
ts: number;
} | null>(null);
@@ -826,7 +827,7 @@ export default function Dashboard() {
>
{/* LOCATE BAR — search by coordinates or place name */}
<LocateBar
onLocate={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })}
onLocate={(lat, lng, bounds) => setFlyToLocation({ lat, lng, bounds, ts: Date.now() })}
onOpenChange={setLocateBarOpen}
/>
+24 -7
View File
@@ -1,6 +1,7 @@
'use client';
import { API_BASE } from '@/lib/api';
import { clampZoom, ZOOM_MAX, ZOOM_FALLBACK } from '@/lib/mapZoom';
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import Map, {
Source,
@@ -751,16 +752,32 @@ const MaplibreViewer = ({
}, [selectedEntity]);
useEffect(() => {
if (flyToLocation && mapRef.current) {
// Agent moves (sar_focus_aoi) carry their own zoom: a world-view revert
// asks for ~2, a tight AOI for ~9. Ignoring it pinned every move at 8,
// which turned "show the whole world" into a patch of empty ocean.
mapRef.current.flyTo({
center: [flyToLocation.lng, flyToLocation.lat],
zoom: flyToLocation.zoom ?? 8,
if (!flyToLocation || !mapRef.current) return;
const map = mapRef.current.getMap();
if (flyToLocation.bounds) {
// cameraForBounds returns undefined when padding exceeds the viewport,
// and silently returns the map's maxZoom (22) for a degenerate box —
// hence both the clamped padding and the explicit maxZoom.
const { clientWidth, clientHeight } = map.getContainer();
const padding = Math.min(64, Math.min(clientWidth, clientHeight) / 6);
const cam = map.cameraForBounds(flyToLocation.bounds, { padding, maxZoom: ZOOM_MAX });
map.flyTo({
center: cam?.center ?? [flyToLocation.lng, flyToLocation.lat],
zoom: cam ? clampZoom(cam.zoom ?? ZOOM_FALLBACK) : ZOOM_FALLBACK,
duration: 1500,
});
return;
}
// Agent moves (sar_focus_aoi) carry their own zoom: a world-view revert
// asks for ~2, a tight AOI for ~9. Ignoring it pinned every move at 8,
// which turned "show the whole world" into a patch of empty ocean.
mapRef.current.flyTo({
center: [flyToLocation.lng, flyToLocation.lat],
zoom: flyToLocation.zoom ?? 8,
duration: 1500,
});
}, [flyToLocation]);
const earthquakesGeoJSON = useMemo(
+251
View File
@@ -0,0 +1,251 @@
/**
* Sizing the LOCATE camera.
*
* Everything here produces a bounding box rather than a zoom number, so the
* caller can hand it to map.cameraForBounds(). That matters: in Web Mercator
* the cos(lat) shrinkage of a longitude degree is exactly cancelled by the
* projection's horizontal stretch, so only the latitude axis needs the term.
* Hand-rolled compensation usually corrects the wrong one. cameraForBounds
* gets both axes and the viewport aspect ratio right for free.
*/
/** [west, south, east, north] - the order MapLibre's LngLatBoundsLike takes. */
export type Bounds = [number, number, number, number];
export const ZOOM_MIN = 2;
/** ~0.6 m/px. Deeper than this, every tile source is upscaled blur. */
export const ZOOM_MAX = 17;
/** OSM.org's own default for a bare coordinate: "somewhere around here". */
export const ZOOM_FALLBACK = 11;
const KM_PER_DEGREE = 111.32;
/**
* A bbox axis is judged "smeared by an outlier" when the result's own
* coordinate sits outside the middle 80% of it. Nominatim's lat/lon is the
* representative point of the main body, so a box that genuinely describes
* that body brackets it; a box stretched to a remote island does not.
*
* This catches the severe cases (Tokyo's box is 70x longer on the far side of
* the city than the near side) and deliberately lets mild ones through.
* Australia keeps a box widened by Heard Island, 4000km to the south-west,
* because its centroid is still only 15% off-centre — no threshold separates
* that from Brazil or Japan, which are similarly off-centre and honest. The
* country still lands on screen whole, just not centred. That is also what
* openstreetmap.org does with the same bounding box.
*/
const CENTRE_MARGIN = 0.1;
/**
* Only axes longer than this are worth judging. Below it, a lopsided box is
* territorial-water padding rather than an outlying territory, and the error
* is too small to see. Monaco's country box is 26km of mostly sea with the
* point on its northern edge; framing that is harmless.
*/
const CENTRE_TEST_MIN_KM = 100;
/**
* No country's true north-south extent reaches this. Canada, the largest,
* spans 41.7N to 83.3N (~4630km). A latitude span beyond the cap is therefore
* an overseas territory dragging the box, not the country.
*/
const MAX_COUNTRY_EXTENT_KM = 5000;
export const clampZoom = (z: number): number =>
Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
const COORD_RE = /^([+-]?\d+(?:\.(\d*))?)[,\s]+([+-]?\d+(?:\.(\d*))?)$/;
/**
* Parse "31.8, 34.8" / "-12.3 45.6", reporting how precisely it was typed.
* Decimals are counted from the raw string because 78 and 78.00 parse to the
* same float but make different claims about precision.
*/
export function parseCoordinateInput(
raw: string,
): { lat: number; lng: number; decimals: number } | null {
const m = raw.trim().match(COORD_RE);
if (!m) return null;
const lat = parseFloat(m[1]);
const lng = parseFloat(m[3]);
if (!(lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180)) return null;
// The less precise axis wins: one sloppy field must not drive the framing.
const decimals = Math.min((m[2] ?? '').length, (m[4] ?? '').length);
return { lat, lng, decimals };
}
/** The uncertainty cell implied by `decimals` places of decimal degrees. */
export function boundsForCoordinate(
lat: number,
lng: number,
decimals: number,
): Bounds {
const half = 0.5 * Math.pow(10, -decimals);
return [
lng - half,
Math.max(-90, lat - half),
lng + half,
Math.min(90, lat + half),
];
}
/**
* Is `value` bracketed by [lo, hi] rather than sitting out at one end?
* Short axes always pass: see CENTRE_TEST_MIN_KM.
*/
function axisBracketsPoint(lo: number, hi: number, value: number): boolean {
const span = hi - lo;
if (span * KM_PER_DEGREE < CENTRE_TEST_MIN_KM) return true;
const position = (value - lo) / span;
return position >= CENTRE_MARGIN && position <= 1 - CENTRE_MARGIN;
}
/**
* Convert a Nominatim boundingbox to Bounds, discarding or repairing the ones
* that would frame the wrong thing. Every rule below is a real query.
*
* `lat`/`lng` are the result's own coordinate. They do the heavy lifting: a
* bounding box is trustworthy when it brackets its own representative point,
* and suspect when that point sits at one edge. Sizing rules cannot do this
* job — the extent Nominatim's place_rank implies is an administrative level,
* not a size, and countries at rank 4 run from Monaco (2km) to Canada
* (4600km). Capping on a multiple of the rank extent throws away the honest
* boxes of every large country and state.
*/
export function sanitizeGeocodeBbox(
bbox: unknown,
lat: number,
lng: number,
): Bounds | null {
if (!Array.isArray(bbox) || bbox.length !== 4) return null;
// Nominatim order is [min_lat, max_lat, min_lon, max_lon], as strings.
const [south, north, west, east] = bbox.map(Number);
if (![south, north, west, east].every(Number.isFinite)) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const latSpan = north - south;
const lonSpan = east - west;
if (latSpan < 0 || lonSpan < 0) return null;
// Russia, the United States, Fiji, New Zealand: a box crossing the
// antimeridian comes back as -180..180, which MapLibre's
// adjustAntiMeridian() reads as a legitimate whole-world box. Longitude is
// unrecoverable, but latitude usually survives, so rebuild a square-ish box
// from that rather than throwing the whole result away.
if (lonSpan >= 359) return boundsFromLatitudeSpan(lat, lng, south, north);
// France: mainland plus Kerguelen and French Polynesia, 350 deg wide but
// never quite touching +/-180, so the dateline rule above misses it.
// Nothing real spans more than a hemisphere of longitude.
if (lonSpan > 180) return null;
// Tokyo: the prefecture's box reaches 1800km south to the Ogasawara
// Islands, leaving the city itself 1.4% from the box's northern edge. The
// box is 15.7 x 18.4 deg — roughly square, so an aspect-ratio test misses
// it, and it is only 5x the extent its rank claims, so a size cap loose
// enough to admit Texas misses it too.
if (!axisBracketsPoint(south, north, lat)) return null;
if (!axisBracketsPoint(west, east, lng)) return null;
// Everest: an OSM node, padded to +/-0.00005 deg. Never zero-area, so a
// degenerate-bounds guard would not catch it - and fitBounds on a box that
// small silently flies to the map's maxZoom.
if (Math.max(latSpan, lonSpan) < 0.0002) return null;
return [west, south, east, north];
}
/**
* Rebuild bounds for a result whose longitude axis was destroyed by the
* antimeridian, using the latitude axis it kept. There is no information left
* about how wide the place is, so the box is made square about the result's
* own longitude — the neutral choice, and one that lands the camera on the
* populated middle of Russia rather than on empty Arctic.
*/
function boundsFromLatitudeSpan(
lat: number,
lng: number,
south: number,
north: number,
): Bounds | null {
const latSpan = north - south;
if (!(latSpan > 0)) return null;
// Territories smear latitude too, so the point still has to be bracketed.
if (!axisBracketsPoint(south, north, lat)) return null;
const extentKm = latSpan * KM_PER_DEGREE;
if (extentKm > MAX_COUNTRY_EXTENT_KM) {
// The United States: American Samoa at 14S and Point Barrow at 71N give
// 9600km of latitude for a 4500km country, and the midpoint of that lands
// in the Caribbean. Neither endpoint is usable, so fall back to a capped
// box around the result's own coordinate.
return boundsAround(lat, lng, MAX_COUNTRY_EXTENT_KM);
}
// Russia: 41N to 82N is the real extent of the country and belongs on the
// camera as-is. Deriving latitude from the result's point instead would
// push the box past 85N, where Mercator's stretch alone forces the zoom
// down to the floor.
const halfLng =
latSpan / 2 / Math.max(Math.cos(((south + north) / 2) * (Math.PI / 180)), 0.01);
return [lng - halfLng, south, lng + halfLng, north];
}
/**
* Nominatim's own extent estimate per search rank, in km. Ranks 13-25 are
* documented; 4-12 are our estimates, since the docs give no figure there.
* Keyed off place_rank rather than class/type/admin_level because Nominatim
* has already normalized admin levels across countries (German admin_level 5
* is rank 10, Swedish admin_level 4 is rank 12).
*/
const RANK_EXTENT_KM: ReadonlyArray<readonly [number, number]> = [
[3, 5000],
[4, 1000],
[8, 250],
[12, 60],
[16, 15],
[18, 4],
[19, 2],
[20, 1],
[25, 0.5],
[27, 0.3],
[30, 0.1],
];
/** Used when Nominatim gave no rank at all, e.g. the local_only fallback. */
const UNKNOWN_RANK = 15;
function extentKmForRank(placeRank?: number): number {
const rank = typeof placeRank === 'number' ? placeRank : UNKNOWN_RANK;
for (const [maxRank, km] of RANK_EXTENT_KM) {
if (rank <= maxRank) return km;
}
return RANK_EXTENT_KM[RANK_EXTENT_KM.length - 1][1];
}
/** A roughly square box `extentKm` across, centred on (lat, lng). */
function boundsAround(lat: number, lng: number, extentKm: number): Bounds {
const halfLat = extentKm / 2 / KM_PER_DEGREE;
// Synthesizing a geographic box, not a pixel measurement, so the cos term
// genuinely applies here. Floored to keep the poles finite.
const cos = Math.max(Math.cos((lat * Math.PI) / 180), 0.01);
const halfLng = halfLat / cos;
return [
lng - halfLng,
Math.max(-90, lat - halfLat),
lng + halfLng,
Math.min(90, lat + halfLat),
];
}
/**
* A box of the rank's typical extent, centred on the result. The last resort,
* for results whose bounding box was unusable and for the local_only path,
* which carries no extent data at all.
*/
export function boundsForPlaceRank(
lat: number,
lng: number,
placeRank?: number,
): Bounds {
return boundsAround(lat, lng, extentKmForRank(placeRank));
}
+1 -1
View File
@@ -1334,7 +1334,7 @@ export interface MaplibreViewerProps {
activeFilters?: Record<string, string[]>;
effects?: MapEffects;
onEntityClick: (entity: SelectedEntity | null) => void;
flyToLocation: { lat: number; lng: number; zoom?: number; ts?: number } | null;
flyToLocation: { lat: number; lng: number; zoom?: number; bounds?: [number, number, number, number]; ts?: number } | null;
selectedEntity: SelectedEntity | null;
onMouseCoords: (coords: { lat: number; lng: number }) => void;
onRightClick: (coords: { lat: number; lng: number }) => void;