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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 © <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 © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
attribution='Radar © <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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user