mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-14 14:40:46 +02:00
Merge pull request #493 from emorchy/feature/locate-auto-zoom
Feature/locate auto zoom
This commit is contained in:
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user