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:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user