feat(map): right-click place dossier (Nominatim + nearby overlays)

Right-click (long-press on touch) opens a What's here? HUD panel: reverse
geocode via GET /api/place (honest UA, 1 req/s, 60s/500-key cache) and lists
already-loaded cameras, aircraft, vessels, fires, and alerts within 5 km.
This commit is contained in:
Sirius DevOps 2026-08-31 21:35:00 -04:00
parent c48788d4b6
commit 5cef596a3c
7 changed files with 566 additions and 6 deletions

View file

@ -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 = os.getenv(
"OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)" "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). # 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 # TITILER_PUBLIC_BASE is the same-origin path prefix the browser hits through

View file

@ -59,6 +59,7 @@ from live_layers import (
fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts, fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts,
overlay_catalog, parse_bbox, UpstreamRateLimited, overlay_catalog, parse_bbox, UpstreamRateLimited,
) )
from place import reverse_geocode
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = structlog.get_logger("osint.dashboard") logger = structlog.get_logger("osint.dashboard")
@ -1729,6 +1730,25 @@ async def list_storms():
_upstream_or_502(exc, "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}$") _GPSJAM_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

99
app/place.py Normal file
View file

@ -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)

View file

@ -364,6 +364,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 { 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); } .gf-del:hover { background: rgba(255,46,151,0.16); }
/* ── Place dossier (“Whats 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 ── */ /* ── Camera / blip popup thumbnails ── */
.cam-pop { min-width: 210px; max-width: 260px; } .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); } .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); }
@ -694,6 +743,14 @@
margin-top: 8px; margin-top: 8px;
margin-right: 8px; margin-right: 8px;
} }
#place-dossier {
top: auto;
right: 8px;
left: 8px;
bottom: 56px;
width: auto;
max-height: 36vh;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tick-track { animation: none; } .tick-track { animation: none; }
@ -932,6 +989,21 @@
</div> </div>
</div> </div>
<aside id="place-dossier" role="dialog" aria-labelledby="pd-title" aria-hidden="true">
<div class="pd-head">
<span id="pd-title">Whats here?</span>
<button type="button" class="pd-close" id="pd-close" aria-label="Close place dossier"></button>
</div>
<div class="pd-body">
<div class="pd-status" id="pd-status">Right-click the map (long-press on touch).</div>
<div class="pd-coords" id="pd-coords"></div>
<div class="pd-name" id="pd-name"></div>
<div class="pd-addr" id="pd-addr"></div>
<div class="pd-sec">Nearby · 5 km</div>
<div class="pd-list" id="pd-nearby"></div>
<div class="pd-note">Loaded overlays only — no world refetch. Nominatim reverse via GET /api/place.</div>
</div>
</aside>
<div class="map-hint" id="map-hint">Initializing…</div> <div class="map-hint" id="map-hint">Initializing…</div>
<div class="hud-chips" id="hud-chips"> <div class="hud-chips" id="hud-chips">
<div class="hud-chip" id="hud-fires"><span class="c fires"></span>FIRMS <b id="hud-fires-n"></b></div> <div class="hud-chip" id="hud-fires"><span class="c fires"></span>FIRMS <b id="hud-fires-n"></b></div>
@ -1931,6 +2003,8 @@ let lastVesselSubBox = ''; // last viewport box sent to the AIS stream
let chokepointCatalog = []; // GET /api/map/chokepoints, fetched once let chokepointCatalog = []; // GET /api/map/chokepoints, fetched once
let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls
let vesselRefollowTimer = null; // one follow-up fetch after a retune 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() { function bboxCell() {
if (!map) return ''; if (!map) return '';
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom(); return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
@ -2122,6 +2196,17 @@ async function initMap() {
map.on('popupclose', () => { camPopupOpen = false; }); map.on('popupclose', () => { camPopupOpen = false; });
map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); }); map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); });
map.on('dragstart', () => { 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', () => { map.on('moveend', () => {
if (camPopupOpen) return; // only the popup's own autopan now if (camPopupOpen) return; // only the popup's own autopan now
if (moveDebounce) clearTimeout(moveDebounce); if (moveDebounce) clearTimeout(moveDebounce);
@ -2466,7 +2551,7 @@ function sinceToISO(sel) {
async function toggleFires() { async function toggleFires() {
firesOn = document.getElementById('lp-fires-on').checked; firesOn = document.getElementById('lp-fires-on').checked;
if (firesOn) await loadFires(); if (firesOn) await loadFires();
else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; } else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; lastFires = []; }
hudFiresCount = null; hudFiresCount = null;
syncHud(); syncHud();
} }
@ -2496,6 +2581,7 @@ async function loadFires() {
if (req !== fireReq) return; // superseded by a newer pan/zoom if (req !== fireReq) return; // superseded by a newer pan/zoom
if (firesHeat) map.removeLayer(firesHeat); if (firesHeat) map.removeLayer(firesHeat);
const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]); const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]);
lastFires = Array.isArray(fires) ? fires : [];
firesHeat = L.heatLayer(pts, { firesHeat = L.heatLayer(pts, {
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2, radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
gradient: firesGradient(), gradient: firesGradient(),
@ -2518,7 +2604,7 @@ async function loadFires() {
async function toggleCams() { async function toggleCams() {
camsOn = document.getElementById('lp-cams-on').checked; camsOn = document.getElementById('lp-cams-on').checked;
if (camsOn) await loadCams(); if (camsOn) await loadCams();
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; } else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; lastCams = []; }
hudCamsCount = null; hudCamsCount = null;
syncHud(); syncHud();
} }
@ -2590,6 +2676,7 @@ async function loadCams() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) { if (tooZoomedOut()) {
camsGroup = dropLayer(camsGroup); camsGroup = dropLayer(camsGroup);
lastCams = [];
markZoom('lp-cams-count'); markZoom('lp-cams-count');
hudCamsCount = null; hudCamsCount = null;
syncHud(); syncHud();
@ -2600,6 +2687,7 @@ async function loadCams() {
const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`); const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`);
const cams = await r.json(); const cams = await r.json();
if (req !== camReq) return; // superseded by a newer pan/zoom if (req !== camReq) return; // superseded by a newer pan/zoom
lastCams = Array.isArray(cams) ? cams : [];
if (camsGroup) map.removeLayer(camsGroup); if (camsGroup) map.removeLayer(camsGroup);
// Clustered markers: camera coverage stays visible as numbered // Clustered markers: camera coverage stays visible as numbered
// clusters at every zoom instead of vanishing into a sparse/empty // clusters at every zoom instead of vanishing into a sparse/empty
@ -3180,12 +3268,13 @@ function loadThermal() {
async function toggleWxAlerts() { async function toggleWxAlerts() {
wxAlertsOn = document.getElementById('lp-alerts-on').checked; wxAlertsOn = document.getElementById('lp-alerts-on').checked;
if (wxAlertsOn) await loadWxAlerts(); if (wxAlertsOn) await loadWxAlerts();
else wxAlertsGroup = dropLayer(wxAlertsGroup); else { wxAlertsGroup = dropLayer(wxAlertsGroup); lastAlerts = []; }
} }
async function loadWxAlerts() { async function loadWxAlerts() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) { if (tooZoomedOut()) {
wxAlertsGroup = dropLayer(wxAlertsGroup); wxAlertsGroup = dropLayer(wxAlertsGroup);
lastAlerts = [];
markZoom('lp-alerts-count'); markZoom('lp-alerts-count');
return; return;
} }
@ -3196,6 +3285,7 @@ async function loadWxAlerts() {
if (req !== overlayReq.alerts) return; if (req !== overlayReq.alerts) return;
wxAlertsGroup = dropLayer(wxAlertsGroup); wxAlertsGroup = dropLayer(wxAlertsGroup);
const feats = fc.features || []; const feats = fc.features || [];
lastAlerts = feats;
wxAlertsGroup = L.geoJSON(fc, { wxAlertsGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }), renderer: L.canvas({ padding: 0.5 }),
style: (f) => ({ style: (f) => ({
@ -3284,7 +3374,7 @@ async function loadIncidents() {
async function toggleAircraft() { async function toggleAircraft() {
acOn = document.getElementById('lp-ac-on').checked; acOn = document.getElementById('lp-ac-on').checked;
if (acOn) await loadAircraft(); if (acOn) await loadAircraft();
else acGroup = dropLayer(acGroup); else { acGroup = dropLayer(acGroup); lastAircraft = []; }
} }
async function loadAircraft() { async function loadAircraft() {
if (!map) return; if (!map) return;
@ -3297,7 +3387,8 @@ async function loadAircraft() {
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`); const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.ac) return; if (req !== overlayReq.ac) return;
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => acColor(p), true, 'ac'); lastAircraft = Array.isArray(pts) ? pts : [];
acGroup = renderPoints(acGroup, lastAircraft, p => acColor(p), true, 'ac');
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString(); document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL'); addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>'); addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>');
@ -3335,6 +3426,7 @@ async function toggleVessels() {
await loadVessels(); await loadVessels();
} else { } else {
vesselsGroup = dropLayer(vesselsGroup); vesselsGroup = dropLayer(vesselsGroup);
lastVessels = [];
clearTimeout(vesselRefollowTimer); clearTimeout(vesselRefollowTimer);
} }
} }
@ -3369,7 +3461,8 @@ async function loadVessels() {
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`); const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.vessels) return; 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 || {}; const extra = p.extra || {};
if (extra.role === 'military') return '#f472b6'; if (extra.role === 'military') return '#f472b6';
if (extra.role === 'government') return '#facc15'; if (extra.role === 'government') return '#facc15';
@ -3411,6 +3504,163 @@ async function loadStorms() {
} }
} }
/* ── Place dossier (“Whats 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 = '<div class="pd-empty">No loaded cameras, aircraft, vessels, fires, or alerts within 5 km.</div>';
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 `<button type="button" class="pd-item" data-lat="${it.lat}" data-lon="${it.lon}">`
+ `<span><span class="k">${esc(it.kind)}</span> ${esc(it.label)}</span>`
+ `<span class="d">${esc(km)}</span></button>`;
}).join('') + (more > 0 ? `<div class="pd-empty">+ ${more} more</div>` : '');
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.'; }
}
}
/* ═══════════════ INITIAL LOAD ═══════════════ */ /* ═══════════════ INITIAL LOAD ═══════════════ */
initNav(); initNav();
initSettings(); initSettings();

View file

@ -138,6 +138,8 @@ services:
FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT} FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT}
FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75} FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)} 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_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66} AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1} AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}

127
tests/test_api_place.py Normal file
View file

@ -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

View file

@ -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 "Whats 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