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
+3
View File
@@ -21,6 +21,9 @@ class Settings(BaseSettings):
openai_api_key: Optional[str] = None openai_api_key: Optional[str] = None
stt_model: str = "gpt-4o-transcribe" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe stt_model: str = "gpt-4o-transcribe" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
# Google Maps (geocoding)
google_maps_api_key: Optional[str] = None
# Gemini (intelligence extraction, embeddings, incident summaries) # Gemini (intelligence extraction, embeddings, incident summaries)
gemini_api_key: Optional[str] = None gemini_api_key: Optional[str] = None
summary_interval_minutes: int = 2 # how often the summary loop runs summary_interval_minutes: int = 2 # how often the summary loop runs
+24 -69
View File
@@ -52,11 +52,8 @@ System: {system_id}
Talkgroup: {talkgroup_name} Talkgroup: {talkgroup_name}
{ten_codes_block}{vocabulary_block}{transcript_block}""" {ten_codes_block}{vocabulary_block}{transcript_block}"""
# Nominatim viewbox half-width in degrees (~11 km at mid-latitudes) # Geographic bias radius for geocoding — half-width in degrees (~55 km)
_GEO_DELTA = 0.5 # ~55 km bias radius; viewbox used as preference, not hard bound _GEO_DELTA = 0.5
# node_id → {"county": str, "state": str} from one-time reverse geocode
_node_place_cache: dict[str, dict] = {}
# Police/law-enforcement phonetic alphabet words (APCO + NATO). # Police/law-enforcement phonetic alphabet words (APCO + NATO).
# A run of 5+ of these in a transcript is a strong Whisper hallucination signal. # 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", ""): if incident_type in ("unknown", "other", ""):
incident_type = None 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 location_coords: Optional[dict] = None
if location and node_lat is not None and node_lon is not 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) muni = _municipality_from_tg(talkgroup_name)
county = place.get("county", "") query = f"{location}, {muni}" if muni else location
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
location_coords = await _geocode_location(query, node_lat, node_lon) location_coords = await _geocode_location(query, node_lat, node_lon)
# Embed this scene's content # Embed this scene's content
@@ -287,37 +279,39 @@ async def _geocode_location(
location_str: str, node_lat: float, node_lon: float location_str: str, node_lat: float, node_lon: float
) -> Optional[dict]: ) -> 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 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). result is farther than geocode_max_km from the node (wrong-jurisdiction guard).
""" """
import httpx import httpx
from app.config import settings from app.config import settings
viewbox = ( if not settings.google_maps_api_key:
f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA}," logger.warning("GOOGLE_MAPS_API_KEY not set — geocoding disabled")
f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}" return None
bounds = (
f"{node_lat - _GEO_DELTA},{node_lon - _GEO_DELTA}"
f"|{node_lat + _GEO_DELTA},{node_lon + _GEO_DELTA}"
) )
params = { params = {
"q": location_str, "address": location_str,
"format": "json", "bounds": bounds,
"limit": 1, "region": "us",
"viewbox": viewbox, "key": settings.google_maps_api_key,
"bounded": 0, # viewbox biases results but doesn't hard-clip them
} }
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get( r = await client.get(
"https://nominatim.openstreetmap.org/search", "https://maps.googleapis.com/maps/api/geocode/json",
params=params, params=params,
headers=headers,
) )
r.raise_for_status() r.raise_for_status()
results = r.json() data = r.json()
if results: if data.get("status") != "OK" or not data.get("results"):
lat = float(results[0]["lat"]) return None
lng = float(results[0]["lon"]) 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) dist_km = _geo_dist_km(node_lat, node_lon, lat, lng)
if dist_km > settings.geocode_max_km: if dist_km > settings.geocode_max_km:
logger.warning( logger.warning(
@@ -333,45 +327,6 @@ async def _geocode_location(
return None 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", "")
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
def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]: def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
""" """
Extract the municipality name from a talkgroup name. Extract the municipality name from a talkgroup name.
+44 -1
View File
@@ -326,12 +326,31 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
const [mapInstance, setMapInstance] = useState<L.Map | null>(null); const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0); const [agoClock, setAgoClock] = useState(0);
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
const [clockStr, setClockStr] = useState(() =>
new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000); const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
// Radar tiles are static once loaded — force remount every 5 min to refresh
useEffect(() => {
const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000);
return () => clearInterval(id);
}, []);
// Live clock for TOC situational awareness
useEffect(() => {
const id = setInterval(() =>
setClockStr(new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })),
1000
);
return () => clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
@@ -419,9 +438,20 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet (no API key) */} {/* Overlay: Traffic — Google Maps traffic layer */}
<LayersControl.Overlay name="Traffic">
<TileLayer
url="https://mt{s}.google.com/vt?lyrs=traffic&x={x}&y={y}&z={z}"
subdomains={["0", "1", "2", "3"]}
attribution='Traffic &copy; <a href="https://maps.google.com/">Google</a>'
opacity={0.8}
/>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
<LayersControl.Overlay name="Weather Radar"> <LayersControl.Overlay name="Weather Radar">
<TileLayer <TileLayer
key={radarEpoch}
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png" url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
attribution='Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>' attribution='Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity={0.65} opacity={0.65}
@@ -460,6 +490,11 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
</button> </button>
)} )}
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 pointer-events-none">
<span className="text-white text-sm font-mono tabular-nums">{clockStr}</span>
</div>
{/* ── Legend — bottom-right to avoid incident panel on left ────────────── */} {/* ── Legend — bottom-right to avoid incident panel on left ────────────── */}
<div className="absolute bottom-8 right-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1"> <div className="absolute bottom-8 right-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
<div className="flex items-center gap-2"><span className="text-green-400"></span> Online</div> <div className="flex items-center gap-2"><span className="text-green-400"></span> Online</div>
@@ -480,6 +515,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
<div className="absolute top-3 left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto"> <div className="absolute top-3 left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto">
{incidents.map((inc) => { {incidents.map((inc) => {
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
const unitCount = inc.units?.length ?? 0;
return ( return (
<button <button
key={inc.incident_id} key={inc.incident_id}
@@ -505,6 +542,12 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
{inc.location && ( {inc.location && (
<p className="text-gray-500 truncate mt-0.5">{inc.location}</p> <p className="text-gray-500 truncate mt-0.5">{inc.location}</p>
)} )}
<div className="flex items-center justify-between mt-0.5">
{age && <span className="text-gray-600">{age}</span>}
{unitCount > 0 && (
<span className="text-gray-600">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
)}
</div>
{!inc.location_coords && ( {!inc.location_coords && (
<p className="text-gray-700 italic mt-0.5">no coords</p> <p className="text-gray-700 italic mt-0.5">no coords</p>
)} )}