diff --git a/app/config.py b/app/config.py
index d93f492..b2bdab8 100644
--- a/app/config.py
+++ b/app/config.py
@@ -71,6 +71,9 @@ FIRMS_DATASETS = [d.strip() for d in _FIRMS_DATASETS_RAW.split(",") if d.strip()
OSINT_USER_AGENT = os.getenv(
"OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)"
)
+# Nominatim reverse (GET /api/place). Camera scraper has its own copy in camera_config.
+NOMINATIM_URL = os.getenv("NOMINATIM_URL", "https://nominatim.openstreetmap.org")
+NOMINATIM_MIN_INTERVAL = float(os.getenv("NOMINATIM_MIN_INTERVAL", "1.0"))
# Self-hosted TiTiler (warps Sentinel-1 signed COGs into XYZ tiles on the Pi).
# TITILER_PUBLIC_BASE is the same-origin path prefix the browser hits through
diff --git a/app/main.py b/app/main.py
index 7a39f85..e53bd9b 100644
--- a/app/main.py
+++ b/app/main.py
@@ -61,6 +61,7 @@ from live_layers import (
fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts,
fetch_infrastructure, overlay_catalog, parse_bbox, UpstreamRateLimited,
)
+from place import reverse_geocode
logging.basicConfig(level=logging.INFO)
logger = structlog.get_logger("osint.dashboard")
@@ -1844,6 +1845,25 @@ async def list_storms():
_upstream_or_502(exc, "storms")
+@app.get("/api/place")
+async def get_place(
+ lat: float = Query(..., ge=-90, le=90),
+ lon: float = Query(..., ge=-180, le=180),
+):
+ """Nominatim reverse geocode for the map \"What's here?\" dossier.
+
+ Identifying ``OSINT_USER_AGENT``, 1 req/s, 60s cache, 500 keys. The HUD
+ lists already-loaded overlay entities client-side — this route does not
+ refetch aircraft/vessels/cameras/fires.
+ """
+ try:
+ return overlay_json(await reverse_geocode(lat, lon), 60)
+ except ValueError as exc:
+ raise HTTPException(422, str(exc)) from exc
+ except Exception as exc:
+ _upstream_or_502(exc, "nominatim")
+
+
_GPSJAM_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
diff --git a/app/place.py b/app/place.py
new file mode 100644
index 0000000..e1729ee
--- /dev/null
+++ b/app/place.py
@@ -0,0 +1,99 @@
+"""Nominatim reverse-geocode proxy for the map place dossier.
+
+Browser clients cannot set an identifying User-Agent, and Nominatim typically
+blocks CORS — so the HUD calls GET /api/place instead of talking to OSM
+directly. Cache 60s / 500 keys; never exceed 1 req/s upstream.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+
+import httpx
+from cachetools import TTLCache
+
+from config import NOMINATIM_MIN_INTERVAL, NOMINATIM_URL, OSINT_USER_AGENT
+
+_NOMINATIM = NOMINATIM_URL.rstrip("/")
+
+place_cache: TTLCache = TTLCache(maxsize=500, ttl=60)
+
+_lock = asyncio.Lock()
+_last_req = 0.0
+
+_ADDR_KEEP = (
+ "house_number", "road", "neighbourhood", "suburb", "city", "town",
+ "village", "hamlet", "county", "state", "postcode", "country", "country_code",
+)
+
+
+def cache_key(lat: float, lon: float) -> str:
+ return f"{lat:.4f},{lon:.4f}"
+
+
+def slim_place(lat: float, lon: float, data: dict | None) -> dict:
+ data = data or {}
+ raw_addr = data.get("address")
+ addr_in: dict = raw_addr if isinstance(raw_addr, dict) else {}
+ address = {k: addr_in[k] for k in _ADDR_KEEP if addr_in.get(k)}
+ err = data.get("error")
+ display = None if err else (data.get("display_name") or None)
+ name = None if err else (data.get("name") or address.get("city")
+ or address.get("town") or address.get("village") or None)
+ return {
+ "lat": lat,
+ "lon": lon,
+ "display_name": display,
+ "name": name,
+ "address": address,
+ "osm_type": None if err else data.get("osm_type"),
+ "osm_id": None if err else data.get("osm_id"),
+ "attribution": "© OpenStreetMap contributors",
+ }
+
+
+async def reverse_geocode(lat: float, lon: float) -> dict:
+ """Reverse-geocode a point. Cache hits skip Nominatim entirely."""
+ if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
+ raise ValueError("lat/lon out of range")
+ key = cache_key(lat, lon)
+ qlat, qlon = (float(p) for p in key.split(","))
+ async with _lock:
+ hit = place_cache.get(key)
+ if hit is not None:
+ return hit
+ global _last_req
+ wait = _last_req + NOMINATIM_MIN_INTERVAL - time.monotonic()
+ if wait > 0:
+ await asyncio.sleep(wait)
+ body = await _fetch_nominatim(qlat, qlon)
+ _last_req = time.monotonic()
+ place_cache[key] = body
+ return body
+
+
+async def _fetch_nominatim(lat: float, lon: float) -> dict:
+ headers = {
+ "User-Agent": OSINT_USER_AGENT,
+ "Accept": "application/json",
+ }
+ url = f"{_NOMINATIM}/reverse"
+ params = {
+ "lat": f"{lat:.6f}",
+ "lon": f"{lon:.6f}",
+ "format": "jsonv2",
+ "addressdetails": "1",
+ "zoom": "18",
+ }
+ async with _http_client(timeout=10.0, follow_redirects=True) as client:
+ r = await client.get(url, params=params, headers=headers)
+ r.raise_for_status()
+ data = r.json()
+ if not isinstance(data, dict):
+ data = {}
+ return slim_place(lat, lon, data)
+
+
+def _http_client(**kwargs):
+ return httpx.AsyncClient(**kwargs)
diff --git a/app/static/index.html b/app/static/index.html
index 86cd36e..32e9346 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -376,6 +376,55 @@
.gf-del { background: transparent; border: 1px solid var(--magenta); color: var(--magenta); font-family: 'Share Tech Mono', monospace; font-size: 0.58rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 0.12rem 0.35rem; border-radius: 3px; cursor: pointer; }
.gf-del:hover { background: rgba(255,46,151,0.16); }
+ /* ── Place dossier (“What’s here?”) ── */
+ #place-dossier {
+ position: absolute; top: 58px; right: 54px; z-index: 650;
+ width: 280px; max-height: calc(100% - 90px); overflow: hidden;
+ background: rgba(6,11,20,0.90); backdrop-filter: blur(8px);
+ border: 1px solid var(--line-hi); border-radius: 6px;
+ box-shadow: 0 0 22px rgba(53,224,255,0.14), 0 0 2px rgba(53,224,255,0.4);
+ color: var(--text); font-size: 0.78rem; display: none; flex-direction: column;
+ clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px));
+ }
+ #place-dossier.open { display: flex; }
+ .pd-head {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 0.55rem 0.95rem 0.55rem 0.85rem; border-bottom: 1px solid var(--line);
+ font-family: 'Orbitron', sans-serif; font-size: 0.68rem; font-weight: 700;
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--cyan);
+ text-shadow: 0 0 10px rgba(53,224,255,0.55);
+ background: linear-gradient(180deg, rgba(53,224,255,0.07), transparent);
+ }
+ .pd-close {
+ background: transparent; border: 1px solid var(--line); color: var(--muted);
+ font-family: 'Share Tech Mono', monospace; font-size: 0.78rem;
+ width: 28px; height: 28px; border-radius: 4px; cursor: pointer; line-height: 1;
+ flex-shrink: 0; margin-right: 2px;
+ }
+ .pd-close:hover { border-color: var(--cyan); color: var(--cyan); }
+ .pd-body { padding: 0.55rem 0.75rem 0.7rem; overflow-y: auto; display: flex; flex-direction: column; gap: 0.55rem; min-height: 0; flex: 1; }
+ .pd-status { font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--muted); }
+ .pd-status.err { color: var(--red); }
+ .pd-coords { font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--cyan); letter-spacing: 0.04em; }
+ .pd-name { font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 0.92rem; color: var(--text); line-height: 1.25; }
+ .pd-addr { font-size: 0.72rem; color: var(--muted); line-height: 1.4; }
+ .pd-sec {
+ font-family: 'Orbitron', sans-serif; font-size: 0.58rem; letter-spacing: 0.12em;
+ text-transform: uppercase; color: var(--muted); margin-top: 0.15rem;
+ }
+ .pd-list { display: flex; flex-direction: column; gap: 0.18rem; }
+ .pd-item {
+ display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;
+ background: transparent; border: 0; border-left: 2px solid var(--line);
+ color: var(--text); text-align: left; cursor: pointer; padding: 0.22rem 0.35rem;
+ font-family: 'Rajdhani', sans-serif; font-size: 0.78rem; font-weight: 600;
+ }
+ .pd-item:hover, .pd-item:focus-visible { border-left-color: var(--cyan); color: var(--cyan); background: rgba(53,224,255,0.06); }
+ .pd-item .k { text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.58rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; flex-shrink: 0; }
+ .pd-item .d { font-family: 'Share Tech Mono', monospace; font-size: 0.62rem; color: var(--cyan); white-space: nowrap; }
+ .pd-empty { font-size: 0.72rem; color: var(--muted); font-style: italic; }
+ .pd-note { font-size: 0.6rem; color: var(--muted); opacity: 0.85; line-height: 1.35; }
+
/* ── Camera / blip popup thumbnails ── */
.cam-pop { min-width: 210px; max-width: 260px; }
.cam-pop .thumb { width: 100%; height: 160px; object-fit: cover; border-radius: 4px; border: 1px solid var(--line); margin: 0.3rem 0; box-shadow: 0 0 10px rgba(53,224,255,0.2); background: var(--bg-0); }
@@ -711,6 +760,14 @@
margin-top: 8px;
margin-right: 8px;
}
+ #place-dossier {
+ top: auto;
+ right: 8px;
+ left: 8px;
+ bottom: 56px;
+ width: auto;
+ max-height: 36vh;
+ }
}
@media (prefers-reduced-motion: reduce) {
.tick-track { animation: none; }
@@ -960,6 +1017,21 @@
+
Initializing…
FIRMS —
@@ -1960,6 +2032,8 @@ let lastVesselSubBox = ''; // last viewport box sent to the AIS stream
let chokepointCatalog = []; // GET /api/map/chokepoints, fetched once
let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls
let vesselRefollowTimer = null; // one follow-up fetch after a retune
+let lastCams = [], lastFires = [], lastAircraft = [], lastVessels = [], lastAlerts = [];
+let placeMarker = null, placeReq = 0, placeLongPress = null;
function bboxCell() {
if (!map) return '';
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
@@ -2183,6 +2257,17 @@ async function initMap() {
map.on('popupclose', () => { camPopupOpen = false; });
map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); });
map.on('dragstart', () => { if (camPopupOpen) map.closePopup(); });
+ map.on('contextmenu', (e) => {
+ if (e.originalEvent) e.originalEvent.preventDefault();
+ if (typeof gfDrawOn !== 'undefined' && gfDrawOn) return;
+ openPlaceDossier(e.latlng);
+ });
+ bindPlaceLongPress(map);
+ const pdClose = document.getElementById('pd-close');
+ if (pdClose) pdClose.addEventListener('click', closePlaceDossier);
+ document.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') closePlaceDossier();
+ });
map.on('moveend', () => {
if (camPopupOpen) return; // only the popup's own autopan now
if (moveDebounce) clearTimeout(moveDebounce);
@@ -2529,7 +2614,7 @@ function sinceToISO(sel) {
async function toggleFires() {
firesOn = document.getElementById('lp-fires-on').checked;
if (firesOn) await loadFires();
- else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; }
+ else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; lastFires = []; }
hudFiresCount = null;
syncHud();
}
@@ -2559,6 +2644,7 @@ async function loadFires() {
if (req !== fireReq) return; // superseded by a newer pan/zoom
if (firesHeat) map.removeLayer(firesHeat);
const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]);
+ lastFires = Array.isArray(fires) ? fires : [];
firesHeat = L.heatLayer(pts, {
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
gradient: firesGradient(),
@@ -2581,7 +2667,7 @@ async function loadFires() {
async function toggleCams() {
camsOn = document.getElementById('lp-cams-on').checked;
if (camsOn) await loadCams();
- else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
+ else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; lastCams = []; }
hudCamsCount = null;
syncHud();
}
@@ -2653,6 +2739,7 @@ async function loadCams() {
if (!map) return;
if (tooZoomedOut()) {
camsGroup = dropLayer(camsGroup);
+ lastCams = [];
markZoom('lp-cams-count');
hudCamsCount = null;
syncHud();
@@ -2663,6 +2750,7 @@ async function loadCams() {
const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`);
const cams = await r.json();
if (req !== camReq) return; // superseded by a newer pan/zoom
+ lastCams = Array.isArray(cams) ? cams : [];
if (camsGroup) map.removeLayer(camsGroup);
// Clustered markers: camera coverage stays visible as numbered
// clusters at every zoom instead of vanishing into a sparse/empty
@@ -3252,12 +3340,13 @@ function loadThermal() {
async function toggleWxAlerts() {
wxAlertsOn = document.getElementById('lp-alerts-on').checked;
if (wxAlertsOn) await loadWxAlerts();
- else wxAlertsGroup = dropLayer(wxAlertsGroup);
+ else { wxAlertsGroup = dropLayer(wxAlertsGroup); lastAlerts = []; }
}
async function loadWxAlerts() {
if (!map) return;
if (tooZoomedOut()) {
wxAlertsGroup = dropLayer(wxAlertsGroup);
+ lastAlerts = [];
markZoom('lp-alerts-count');
return;
}
@@ -3268,6 +3357,7 @@ async function loadWxAlerts() {
if (req !== overlayReq.alerts) return;
wxAlertsGroup = dropLayer(wxAlertsGroup);
const feats = fc.features || [];
+ lastAlerts = feats;
wxAlertsGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }),
style: (f) => ({
@@ -3356,7 +3446,7 @@ async function loadIncidents() {
async function toggleAircraft() {
acOn = document.getElementById('lp-ac-on').checked;
if (acOn) await loadAircraft();
- else acGroup = dropLayer(acGroup);
+ else { acGroup = dropLayer(acGroup); lastAircraft = []; }
}
function toggleAircraftMil() {
acMilOn = document.getElementById('lp-ac-mil-on').checked;
@@ -3376,6 +3466,7 @@ async function loadAircraft() {
const all = Array.isArray(pts) ? pts : [];
noteMilSupport(all);
const shown = acMilOn ? all.filter(acVisible) : all;
+ lastAircraft = shown;
acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac');
document.getElementById('lp-ac-count').textContent = shown.length.toLocaleString();
const milEl = document.getElementById('lp-ac-mil-count');
@@ -3420,6 +3511,7 @@ async function toggleVessels() {
await loadVessels();
} else {
vesselsGroup = dropLayer(vesselsGroup);
+ lastVessels = [];
clearTimeout(vesselRefollowTimer);
}
}
@@ -3454,7 +3546,8 @@ async function loadVessels() {
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`);
const pts = await r.json();
if (req !== overlayReq.vessels) return;
- vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
+ lastVessels = Array.isArray(pts) ? pts : [];
+ vesselsGroup = renderPoints(vesselsGroup, lastVessels, p => {
const extra = p.extra || {};
if (extra.role === 'military') return '#f472b6';
if (extra.role === 'government') return '#facc15';
@@ -3496,6 +3589,163 @@ async function loadStorms() {
}
}
+/* ── Place dossier (“What’s here?”) — Nominatim + already-loaded overlays ── */
+const PLACE_PAD_KM = 5;
+function haversineKm(aLat, aLon, bLat, bLon) {
+ const toRad = (d) => d * Math.PI / 180;
+ const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon);
+ const a = Math.sin(dLat / 2) ** 2
+ + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
+ return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
+}
+function placePt(p) {
+ const lat = p.lat ?? p.latitude;
+ const lon = p.lon ?? p.longitude;
+ if (lat == null || lon == null) return null;
+ return { lat: Number(lat), lon: Number(lon) };
+}
+function nearbyFromPoints(rows, origin, kind, labelFn) {
+ const out = [];
+ (rows || []).forEach((p) => {
+ const pt = placePt(p);
+ if (!pt) return;
+ const km = haversineKm(origin.lat, origin.lng, pt.lat, pt.lon);
+ if (km <= PLACE_PAD_KM) out.push({ kind, km, label: labelFn(p), lat: pt.lat, lon: pt.lon });
+ });
+ return out;
+}
+function alertCentroid(f) {
+ const g = f && f.geometry;
+ if (!g) return null;
+ let c = g.coordinates;
+ if (g.type === 'Point' && Array.isArray(c)) return { lat: c[1], lon: c[0] };
+ while (Array.isArray(c) && Array.isArray(c[0])) c = c[0];
+ if (Array.isArray(c) && typeof c[0] === 'number') return { lat: c[1], lon: c[0] };
+ return null;
+}
+function nearbyFromAlerts(feats, origin) {
+ const out = [];
+ (feats || []).forEach((f) => {
+ const p = f.properties || {};
+ const label = p.event || p.headline || 'Alert';
+ const pt = alertCentroid(f);
+ if (!pt) return;
+ const km = haversineKm(origin.lat, origin.lng, pt.lat, pt.lon);
+ if (km <= PLACE_PAD_KM) out.push({ kind: 'alert', km, label, lat: pt.lat, lon: pt.lon });
+ });
+ return out;
+}
+function collectNearby(origin) {
+ const items = [
+ ...nearbyFromPoints(lastCams, origin, 'camera', (p) => p.location_name || 'Camera'),
+ ...nearbyFromPoints(lastAircraft, origin, 'aircraft', (p) => p.label || (p.extra || {}).hex || 'Aircraft'),
+ ...nearbyFromPoints(lastVessels, origin, 'vessel', (p) => p.label || 'Vessel'),
+ ...nearbyFromPoints(lastFires, origin, 'fire', () => 'FIRMS hotspot'),
+ ...nearbyFromAlerts(lastAlerts, origin),
+ ];
+ items.sort((a, b) => a.km - b.km);
+ return items;
+}
+function renderNearby(origin) {
+ const box = document.getElementById('pd-nearby');
+ if (!box) return;
+ const items = collectNearby(origin);
+ if (!items.length) {
+ box.innerHTML = '
No loaded cameras, aircraft, vessels, fires, or alerts within 5 km.
';
+ return;
+ }
+ const shown = items.slice(0, 24);
+ const more = items.length - shown.length;
+ box.innerHTML = shown.map((it) => {
+ const km = it.km < 1 ? `${Math.round(it.km * 1000)} m` : `${it.km.toFixed(1)} km`;
+ return `
`;
+ }).join('') + (more > 0 ? `
+ ${more} more
` : '');
+ box.querySelectorAll('.pd-item').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const lat = Number(btn.dataset.lat), lon = Number(btn.dataset.lon);
+ if (!map || Number.isNaN(lat) || Number.isNaN(lon)) return;
+ map.setView([lat, lon], Math.max(map.getZoom(), 10));
+ });
+ });
+}
+function closePlaceDossier() {
+ const panel = document.getElementById('place-dossier');
+ if (panel) {
+ panel.classList.remove('open');
+ panel.setAttribute('aria-hidden', 'true');
+ }
+ if (placeMarker && map) { map.removeLayer(placeMarker); placeMarker = null; }
+}
+function bindPlaceLongPress(m) {
+ const el = m.getContainer();
+ const clear = () => {
+ if (placeLongPress) { clearTimeout(placeLongPress.timer); placeLongPress = null; }
+ };
+ el.addEventListener('touchstart', (ev) => {
+ if (ev.touches.length !== 1) { clear(); return; }
+ const t = ev.touches[0];
+ placeLongPress = {
+ x: t.clientX, y: t.clientY,
+ timer: setTimeout(() => {
+ const start = placeLongPress;
+ placeLongPress = null;
+ if (!start || gfDrawOn) return;
+ const latlng = m.mouseEventToLatLng({ clientX: start.x, clientY: start.y });
+ openPlaceDossier(latlng);
+ }, 550),
+ };
+ }, { passive: true });
+ el.addEventListener('touchmove', (ev) => {
+ if (!placeLongPress) return;
+ const t = ev.touches[0];
+ if (Math.hypot(t.clientX - placeLongPress.x, t.clientY - placeLongPress.y) > 14) clear();
+ }, { passive: true });
+ el.addEventListener('touchend', clear);
+ el.addEventListener('touchcancel', clear);
+ el.addEventListener('contextmenu', (ev) => ev.preventDefault());
+}
+async function openPlaceDossier(latlng) {
+ if (!latlng || !map) return;
+ const panel = document.getElementById('place-dossier');
+ const status = document.getElementById('pd-status');
+ const coords = document.getElementById('pd-coords');
+ const nameEl = document.getElementById('pd-name');
+ const addrEl = document.getElementById('pd-addr');
+ if (!panel) return;
+ panel.classList.add('open');
+ panel.setAttribute('aria-hidden', 'false');
+ const lat = latlng.lat, lon = latlng.lng;
+ if (coords) coords.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
+ if (nameEl) nameEl.textContent = '';
+ if (addrEl) addrEl.textContent = '';
+ if (status) { status.classList.remove('err'); status.textContent = 'Looking up place…'; }
+ if (placeMarker) map.removeLayer(placeMarker);
+ placeMarker = L.circleMarker([lat, lon], {
+ radius: 7, color: '#35e0ff', weight: 2, fillColor: '#35e0ff', fillOpacity: 0.25,
+ pane: 'markerPane',
+ }).addTo(map);
+ renderNearby(latlng);
+ const req = ++placeReq;
+ try {
+ const r = await fetch(`${API}/api/place?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`);
+ if (req !== placeReq) return;
+ if (!r.ok) throw new Error(`place ${r.status}`);
+ const d = await r.json();
+ if (req !== placeReq) return;
+ if (nameEl) nameEl.textContent = d.name || d.display_name || 'Unknown place';
+ if (addrEl) addrEl.textContent = d.display_name && d.name && d.display_name !== d.name
+ ? d.display_name : '';
+ if (status) status.textContent = d.display_name ? 'Nominatim · OSM' : 'No reverse geocode for this point';
+ const closeBtn = document.getElementById('pd-close');
+ if (closeBtn) closeBtn.focus();
+ } catch (e) {
+ if (req !== placeReq) return;
+ if (status) { status.classList.add('err'); status.textContent = 'Place lookup failed — nearby list is still from loaded overlays.'; }
+ }
+}
+
function conflictSeverityColor(sev) {
const s = String(sev || '').toLowerCase();
if (s === 'war') return '#ff2a6d';
diff --git a/docker-compose.yml b/docker-compose.yml
index 76d0431..01abd37 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -138,6 +138,8 @@ services:
FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT}
FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)}
+ NOMINATIM_URL: ${NOMINATIM_URL:-https://nominatim.openstreetmap.org}
+ NOMINATIM_MIN_INTERVAL: ${NOMINATIM_MIN_INTERVAL:-1.0}
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}
diff --git a/tests/test_api_place.py b/tests/test_api_place.py
new file mode 100644
index 0000000..52e15ad
--- /dev/null
+++ b/tests/test_api_place.py
@@ -0,0 +1,127 @@
+"""GET /api/place — Nominatim reverse proxy (60s cache, 500 keys, 1 req/s)."""
+
+from __future__ import annotations
+
+import asyncio
+
+import httpx
+import pytest
+
+from main import app
+from place import cache_key, place_cache, slim_place
+
+BASE = "http://test"
+
+SAMPLE = {
+ "display_name": "Raleigh, Wake County, North Carolina, United States",
+ "name": "Raleigh",
+ "osm_type": "relation",
+ "osm_id": 123,
+ "address": {
+ "city": "Raleigh",
+ "state": "North Carolina",
+ "country": "United States",
+ "country_code": "us",
+ "tourism": "ignore-me",
+ },
+}
+
+
+class _FakeResp:
+ def __init__(self, payload, status=200):
+ self._payload = payload
+ self.status_code = status
+
+ def raise_for_status(self):
+ if self.status_code >= 400:
+ req = httpx.Request("GET", "https://nominatim.openstreetmap.org/reverse")
+ raise httpx.HTTPStatusError(
+ "upstream", request=req,
+ response=httpx.Response(self.status_code, request=req),
+ )
+
+ def json(self):
+ return self._payload
+
+
+class _FakeNominatim:
+ calls: list[dict] = []
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return False
+
+ async def get(self, url, params=None, headers=None):
+ _FakeNominatim.calls.append({"url": url, "params": params, "headers": headers})
+ return _FakeResp(SAMPLE)
+
+
+def _nominatim_client(**kwargs):
+ return _FakeNominatim()
+
+
+async def _get(path: str) -> httpx.Response:
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
+ return await client.get(path)
+
+
+@pytest.fixture(autouse=True)
+def _reset_place(monkeypatch):
+ place_cache.clear()
+ _FakeNominatim.calls = []
+ monkeypatch.setattr("place._http_client", _nominatim_client)
+ monkeypatch.setattr("place.NOMINATIM_MIN_INTERVAL", 0.0)
+ monkeypatch.setattr("place._last_req", 0.0)
+ yield
+ place_cache.clear()
+
+
+def test_slim_place_keeps_address_subset():
+ body = slim_place(35.78, -78.64, SAMPLE)
+ assert body["display_name"].startswith("Raleigh")
+ assert body["name"] == "Raleigh"
+ assert body["address"]["city"] == "Raleigh"
+ assert "tourism" not in body["address"]
+ assert body["attribution"].startswith("© OpenStreetMap")
+
+
+def test_cache_key_quantizes_to_4_decimals():
+ assert cache_key(35.77961, -78.63821) == cache_key(35.77964, -78.63819)
+
+
+def test_place_requires_lat_lon():
+ resp = asyncio.run(_get("/api/place"))
+ assert resp.status_code == 422
+
+
+def test_place_rejects_out_of_range():
+ assert asyncio.run(_get("/api/place?lat=99&lon=0")).status_code == 422
+ assert asyncio.run(_get("/api/place?lat=0&lon=200")).status_code == 422
+
+
+def test_place_reverse_and_cache():
+ r1 = asyncio.run(_get("/api/place?lat=35.7796&lon=-78.6382"))
+ assert r1.status_code == 200
+ body = r1.json()
+ assert body["display_name"].startswith("Raleigh")
+ assert body["lat"] == pytest.approx(35.7796, abs=0.001)
+ assert "max-age=60" in (r1.headers.get("cache-control") or "").lower()
+ assert len(_FakeNominatim.calls) == 1
+ ua = _FakeNominatim.calls[0]["headers"]["User-Agent"]
+ assert "osint-dashboard" in ua.lower() or "@" in ua
+ r2 = asyncio.run(_get("/api/place?lat=35.77961&lon=-78.63821"))
+ assert r2.status_code == 200
+ assert len(_FakeNominatim.calls) == 1 # cache hit, same 4-decimal key
+
+
+def test_place_cache_cap_500():
+ from cachetools import TTLCache
+ assert isinstance(place_cache, TTLCache)
+ assert place_cache.maxsize == 500
+ assert place_cache.ttl == 60
diff --git a/tests/test_place_dossier_frontend.py b/tests/test_place_dossier_frontend.py
new file mode 100644
index 0000000..6fc1fd8
--- /dev/null
+++ b/tests/test_place_dossier_frontend.py
@@ -0,0 +1,59 @@
+"""Right-click place dossier HUD contract."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+HTML = (ROOT / "app/static/index.html").read_text()
+
+
+def test_place_dossier_panel_markup():
+ assert 'id="place-dossier"' in HTML
+ assert "What’s here?" in HTML or "What's here?" in HTML
+ assert 'id="pd-nearby"' in HTML
+ assert 'id="pd-close"' in HTML
+ assert 'role="dialog"' in HTML
+
+
+def test_place_dossier_uses_backend_nominatim_proxy():
+ js = HTML.split("async function openPlaceDossier", 1)[1].split(
+ "/* ═══════════════ INITIAL LOAD", 1
+ )[0]
+ assert "/api/place?lat=" in js
+ assert "nominatim.openstreetmap.org" not in js
+ assert "/api/aircraft" not in js
+ assert "/api/vessels" not in js
+ assert "/api/cameras" not in js
+ assert "/api/fires" not in js
+ assert "/api/weather-alerts" not in js
+ assert "/api/infrastructure" not in js
+
+
+def test_place_dossier_scans_loaded_overlays_5km():
+ assert "const PLACE_PAD_KM = 5" in HTML
+ assert "function collectNearby" in HTML
+ assert "lastCams" in HTML
+ assert "lastAircraft" in HTML
+ assert "lastVessels" in HTML
+ assert "lastFires" in HTML
+ assert "lastAlerts" in HTML
+ assert "function haversineKm" in HTML
+
+
+def test_place_dossier_right_click_and_long_press():
+ assert "map.on('contextmenu'" in HTML
+ assert "function bindPlaceLongPress" in HTML
+ assert "function closePlaceDossier" in HTML
+ assert "Escape" in HTML.split("function initMap", 1)[1][:8000] or "Escape" in HTML.split(
+ "bindPlaceLongPress(map)", 1
+ )[0][-500:]
+
+
+def test_place_dossier_mobile_is_bottom_sheet():
+ mobile = HTML.split("@media (max-width: 820px)")[1].split(
+ "@media (prefers-reduced-motion"
+ )[0]
+ assert "#place-dossier" in mobile
+ assert "bottom: 56px" in mobile
+ assert "max-height: 36vh" in mobile