feat: replace Nominatim geocoding with Google Maps API; add TOC map improvements

Switch geocoding from Nominatim to Google Maps Geocoding API for accurate
local place name resolution (bounds-biased, with 25km distance rejection guard).
Remove the now-unused _get_node_place reverse-geocoder and _node_place_cache.

Map page (TOC improvements):
- Weather radar tiles auto-refresh every 5 minutes via radarEpoch key cycling
- Google Maps traffic overlay added to LayersControl
- Live 24h clock overlay at bottom-left for situational awareness
- Incident sidebar cards now show age (time since dispatch) and unit count
This commit is contained in:
Logan
2026-05-25 13:27:19 -04:00
parent 0db09d6bf7
commit 0279a82b10
3 changed files with 83 additions and 82 deletions
+36 -81
View File
@@ -52,11 +52,8 @@ System: {system_id}
Talkgroup: {talkgroup_name}
{ten_codes_block}{vocabulary_block}{transcript_block}"""
# Nominatim viewbox half-width in degrees (~11 km at mid-latitudes)
_GEO_DELTA = 0.5 # ~55 km bias radius; viewbox used as preference, not hard bound
# node_id → {"county": str, "state": str} from one-time reverse geocode
_node_place_cache: dict[str, dict] = {}
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
_GEO_DELTA = 0.5
# Police/law-enforcement phonetic alphabet words (APCO + NATO).
# A run of 5+ of these in a transcript is a strong Whisper hallucination signal.
@@ -197,18 +194,13 @@ async def extract_scenes(
if incident_type in ("unknown", "other", ""):
incident_type = None
# Geocode this scene's location
# Geocode this scene's location.
# Include the municipality from the talkgroup name to help Google
# resolve ambiguous local names (e.g. "Pinebrook" → "Pinebrook, Yorktown").
location_coords: Optional[dict] = None
if location and node_lat is not None and node_lon is not None:
place = await _get_node_place(node_id, node_lat, node_lon)
muni = _municipality_from_tg(talkgroup_name)
county = place.get("county", "")
state = place.get("state", "")
# Build hint from most specific to least: municipality → county → state.
# Including county prevents common street names (e.g. "East Main Street")
# from resolving to a wrong county when the address is ambiguous.
hint_parts = [p for p in [muni, county, state] if p]
query = f"{location}, {', '.join(hint_parts)}" if hint_parts else location
muni = _municipality_from_tg(talkgroup_name)
query = f"{location}, {muni}" if muni else location
location_coords = await _geocode_location(query, node_lat, node_lon)
# Embed this scene's content
@@ -287,89 +279,52 @@ async def _geocode_location(
location_str: str, node_lat: float, node_lon: float
) -> Optional[dict]:
"""
Geocode a location string using Nominatim, biased toward the node's area.
Geocode using Google Maps Geocoding API, biased toward the node's area.
Returns {"lat": float, "lng": float} or None if geocoding fails or the
result is farther than geocode_max_km from the node (wrong-jurisdiction guard).
"""
import httpx
from app.config import settings
viewbox = (
f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA},"
f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}"
if not settings.google_maps_api_key:
logger.warning("GOOGLE_MAPS_API_KEY not set — geocoding disabled")
return None
bounds = (
f"{node_lat - _GEO_DELTA},{node_lon - _GEO_DELTA}"
f"|{node_lat + _GEO_DELTA},{node_lon + _GEO_DELTA}"
)
params = {
"q": location_str,
"format": "json",
"limit": 1,
"viewbox": viewbox,
"bounded": 0, # viewbox biases results but doesn't hard-clip them
"address": location_str,
"bounds": bounds,
"region": "us",
"key": settings.google_maps_api_key,
}
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
"https://nominatim.openstreetmap.org/search",
"https://maps.googleapis.com/maps/api/geocode/json",
params=params,
headers=headers,
)
r.raise_for_status()
results = r.json()
if results:
lat = float(results[0]["lat"])
lng = float(results[0]["lon"])
dist_km = _geo_dist_km(node_lat, node_lon, lat, lng)
if dist_km > settings.geocode_max_km:
logger.warning(
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
f"{dist_km:.1f}km from node exceeds geocode_max_km={settings.geocode_max_km}"
)
return None
coords = {"lat": lat, "lng": lng}
logger.info(f"Geocoded '{location_str}'{coords} ({dist_km:.1f}km from node)")
return coords
except Exception as e:
logger.warning(f"Geocoding failed for '{location_str}': {e}")
return None
async def _get_node_place(node_id: str, lat: float, lon: float) -> dict:
"""
Reverse geocode the node's position once to extract county + state.
Uses zoom=10 so Nominatim returns county-level granularity, which is
included in geocoding queries to prevent common street names from resolving
to a wrong county (e.g. "East Main Street" in Orange vs. Westchester).
Result is cached for the process lifetime — nodes don't move.
Returns dict with "county" and "state" keys (empty string if not found).
"""
if node_id in _node_place_cache:
return _node_place_cache[node_id]
import httpx
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
place: dict = {"county": "", "state": ""}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
"https://nominatim.openstreetmap.org/reverse",
params={"lat": lat, "lon": lon, "format": "json", "zoom": 10},
headers=headers,
)
r.raise_for_status()
data = r.json()
addr = data.get("address", {})
place["county"] = addr.get("county", "")
place["state"] = addr.get("state", "")
if data.get("status") != "OK" or not data.get("results"):
return None
loc = data["results"][0]["geometry"]["location"]
lat, lng = float(loc["lat"]), float(loc["lng"])
dist_km = _geo_dist_km(node_lat, node_lon, lat, lng)
if dist_km > settings.geocode_max_km:
logger.warning(
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
f"{dist_km:.1f}km from node exceeds geocode_max_km={settings.geocode_max_km}"
)
return None
coords = {"lat": lat, "lng": lng}
logger.info(f"Geocoded '{location_str}'{coords} ({dist_km:.1f}km from node)")
return coords
except Exception as e:
logger.warning(f"Node place reverse geocode failed: {e}")
if place["county"] or place["state"]:
_node_place_cache[node_id] = place
logger.info(
f"Node {node_id} reverse-geocoded: county={place['county']!r}, "
f"state={place['state']!r}"
)
return place
logger.warning(f"Geocoding failed for '{location_str}': {e}")
return None
def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]: