diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html index 05879f9..aa5433d 100644 --- a/src/world_intel_mcp/dashboard/index.html +++ b/src/world_intel_mcp/dashboard/index.html @@ -401,6 +401,36 @@ a { color: var(--accent); text-decoration: none; } .mk-waterway:hover { transform: rotate(45deg) scale(1.5); opacity: 1; } .cable-corridor { cursor: pointer; transition: fill-opacity 0.15s; } .cable-corridor:hover { fill-opacity: 0.1 !important; stroke-opacity: 0.5 !important; } +.mk-plane { + width: 4px; height: 4px; border-radius: 50%; + background: rgba(0,200,255,0.5); cursor: pointer; + transition: transform 0.15s; +} +.mk-plane.comm { background: rgba(0,200,255,0.7); } +.mk-plane:hover { transform: scale(3); } +.mk-traffic { + border-radius: 50%; + border: 2px solid rgba(255,160,0,0.5); + background: radial-gradient(circle, rgba(255,160,0,0.5) 30%, rgba(255,160,0,0) 70%); + cursor: pointer; transition: transform 0.15s; +} +.mk-traffic:hover { transform: scale(1.3); } +.mk-traffic.crit { border-color: rgba(255,59,59,0.6); background: radial-gradient(circle, rgba(255,59,59,0.5) 30%, rgba(255,59,59,0) 70%); } +.mk-navwarn { + width: 6px; height: 6px; border-radius: 1px; + transform: rotate(45deg); + background: rgba(255,200,50,0.7); + border: 1px solid rgba(255,200,50,0.5); + cursor: pointer; transition: transform 0.15s; +} +.mk-navwarn:hover { transform: rotate(45deg) scale(1.8); } +.mk-cam { + width: 7px; height: 7px; border-radius: 50%; + background: rgba(120,220,255,0.6); + border: 1px solid rgba(120,220,255,0.4); + cursor: pointer; transition: transform 0.15s; +} +.mk-cam:hover { transform: scale(1.8); } @keyframes glow-gold { 0%,100% { box-shadow: 0 0 4px rgba(240,184,64,0.3); } 50% { box-shadow: 0 0 16px rgba(240,184,64,0.7); } @@ -519,6 +549,30 @@ a { color: var(--accent); text-decoration: none; } 0
+
+ + Air Traffic + 0 +
+
+
+ + Road Traffic + 0 +
+
+
+ + Nav Warnings + 0 +
+
+
+ + Webcams + 0 +
+
@@ -565,7 +619,7 @@ function ago(ts) { if (!ts) return '\u2014'; var s = Math.floor((Date.now() - ne var latestData = null; var map = null; var mapLayers = {}; -var layerState = { quakes: true, military: true, conflict: true, fires: true, convergence: true, nuclear: true, infra: true, exposure: true }; +var layerState = { quakes: true, military: true, conflict: true, fires: true, convergence: true, nuclear: true, infra: true, exposure: true, airtraffic: true, traffic: true, navwarnings: true, webcams: true }; var drawerOpen = false; // ════════════ MAP INIT ════════════ @@ -581,6 +635,10 @@ function initMap() { mapLayers.nuclear = L.layerGroup().addTo(map); mapLayers.infra = L.layerGroup().addTo(map); mapLayers.exposure = L.layerGroup().addTo(map); + mapLayers.airtraffic = L.layerGroup().addTo(map); + mapLayers.traffic = L.layerGroup().addTo(map); + mapLayers.navwarnings = L.layerGroup().addTo(map); + mapLayers.webcams = L.layerGroup().addTo(map); } // ════════════ LAYER TOGGLES ════════════ @@ -605,8 +663,8 @@ $('#drawerToggle').addEventListener('click', function() { }); // ════════════ DETAIL MODAL ════════════ -var COLORS = { earthquake: 'var(--red)', military: 'var(--blue)', conflict: 'var(--amber)', fire: 'var(--gold)', convergence: 'var(--purple)', nuclear: 'var(--green)', military_base: 'var(--teal)', port: 'var(--blue)', nuclear_facility: 'var(--gold)', pipeline: 'var(--purple)', exposure: '#e040fb', waterway: 'var(--accent)', cable: 'var(--blue)' }; -var LABELS = { earthquake: 'EARTHQUAKE', military: 'MILITARY AIRCRAFT', conflict: 'CONFLICT EVENT', fire: 'WILDFIRE CLUSTER', convergence: 'SIGNAL CONVERGENCE', nuclear: 'NUCLEAR MONITOR', military_base: 'MILITARY BASE', port: 'STRATEGIC PORT', nuclear_facility: 'NUCLEAR FACILITY', pipeline: 'PIPELINE', exposure: 'POPULATION EXPOSURE', waterway: 'STRATEGIC WATERWAY', cable: 'CABLE CORRIDOR' }; +var COLORS = { earthquake: 'var(--red)', military: 'var(--blue)', conflict: 'var(--amber)', fire: 'var(--gold)', convergence: 'var(--purple)', nuclear: 'var(--green)', military_base: 'var(--teal)', port: 'var(--blue)', nuclear_facility: 'var(--gold)', pipeline: 'var(--purple)', exposure: '#e040fb', waterway: 'var(--accent)', cable: 'var(--blue)', airtraffic: '#00c8ff', traffic_city: '#ffa000', navwarn: '#ffc832', webcam: '#78dcff' }; +var LABELS = { earthquake: 'EARTHQUAKE', military: 'MILITARY AIRCRAFT', conflict: 'CONFLICT EVENT', fire: 'WILDFIRE CLUSTER', convergence: 'SIGNAL CONVERGENCE', nuclear: 'NUCLEAR MONITOR', military_base: 'MILITARY BASE', port: 'STRATEGIC PORT', nuclear_facility: 'NUCLEAR FACILITY', pipeline: 'PIPELINE', exposure: 'POPULATION EXPOSURE', waterway: 'STRATEGIC WATERWAY', cable: 'CABLE CORRIDOR', airtraffic: 'AIRCRAFT', traffic_city: 'TRAFFIC CONGESTION', navwarn: 'NAVIGATION WARNING', webcam: 'WEBCAM' }; function showDetail(type, d) { var title = '', subtitle = '', fields = [], link = ''; @@ -915,6 +973,51 @@ function showDrawerDetail(id) { ['Summary', trunc(d.summary || '', 500)] ]; link = d.link || ''; + } else if (type === 'airtraffic') { + title = d.callsign || 'Aircraft'; + subtitle = d.origin || ''; + fields = [ + ['Callsign', d.callsign || '\u2014'], + ['Origin', d.origin || '\u2014'], + ['Altitude', d.alt != null ? d.alt + ' m (' + Math.round(d.alt * 3.281) + ' ft)' : '\u2014'], + ['Type', d.commercial ? 'Commercial' : 'General Aviation'], + ['Coordinates', d.lat != null ? d.lat + ', ' + d.lon : '\u2014'] + ]; + } else if (type === 'traffic_city') { + title = d.name || 'City'; + subtitle = d.country || ''; + var congPct = d.congestion_pct || 0; + fields = [ + ['City', d.name || '\u2014'], + ['Country', d.country || '\u2014'], + ['Congestion', congPct + '%', congPct >= 50 ? 'crit' : congPct >= 25 ? 'high' : ''], + ['Current Speed', d.current_speed_kmh + ' km/h'], + ['Free Flow', d.free_flow_speed_kmh + ' km/h'], + ['Coordinates', d.lat + ', ' + d.lon] + ]; + } else if (type === 'webcam') { + title = d.title || 'Camera'; + subtitle = (d.city || '') + (d.country ? ', ' + d.country : ''); + fields = [ + ['Title', d.title || '\u2014'], + ['City', d.city || '\u2014'], + ['Country', d.country || '\u2014'], + ['Status', d.status || '\u2014'], + ['Coordinates', d.lat != null ? d.lat + ', ' + d.lon : '\u2014'] + ]; + link = d.player_url || ''; + } else if (type === 'navwarn') { + title = 'NAVAREA ' + (d.navarea || '?') + ' \u2014 ' + (d.id || ''); + subtitle = d.status || ''; + fields = [ + ['ID', d.id || '\u2014'], + ['NAVAREA', d.navarea || '\u2014'], + ['Subregion', d.subregion || '\u2014'], + ['Status', d.status || '\u2014'], + ['Issued', d.issue_date || '\u2014'], + ['Authority', d.authority || '\u2014'], + ['Text', d.text || '\u2014'] + ]; } else { title = d.title || d.name || JSON.stringify(d).slice(0, 80); fields = Object.keys(d).slice(0, 12).map(function(k) { return [k, trunc(String(d[k] || ''), 120)]; }); @@ -1233,6 +1336,78 @@ function updateMapExposure(data) { }); } +function updateMapAirTraffic(data) { + mapLayers.airtraffic.clearLayers(); + if (!data || data.error) { $('#cntAirTraffic').textContent = '0'; return; } + var positions = data.positions || []; + $('#cntAirTraffic').textContent = data.total_aircraft || positions.length; + positions.forEach(function(p) { + if (p.lat == null || p.lon == null) return; + var mk = L.marker([p.lat, p.lon], { + icon: L.divIcon({ className: 'mk-plane' + (p.commercial ? ' comm' : ''), iconSize: [4, 4], iconAnchor: [2, 2] }) + }); + var tip = (p.callsign || '?') + ' \u2014 ' + (p.origin || '?'); + if (p.alt) tip += ' @ ' + p.alt + 'm'; + mk.bindTooltip(tip, { className: 'mk-tip', direction: 'top', offset: [0, -3] }); + mk.on('click', function() { showDetail('airtraffic', p); }); + mk.addTo(mapLayers.airtraffic); + }); +} + +function updateMapTraffic(data) { + mapLayers.traffic.clearLayers(); + if (!data || data.error) { $('#cntTraffic').textContent = '0'; return; } + var cities = data.cities || []; + $('#cntTraffic').textContent = cities.length; + cities.forEach(function(c) { + if (c.lat == null || c.lon == null) return; + var pct = c.congestion_pct || 0; + var size = Math.max(14, Math.min(30, pct / 2 + 10)); + var cls = pct >= 50 ? ' crit' : ''; + var mk = L.marker([c.lat, c.lon], { + icon: L.divIcon({ className: 'mk-traffic' + cls, iconSize: [size, size], iconAnchor: [size/2, size/2] }) + }); + mk.bindTooltip(esc(c.name) + ' ' + pct + '% congestion', { className: 'mk-tip', direction: 'top', offset: [0, -size/2] }); + mk.on('click', function() { showDetail('traffic_city', c); }); + mk.addTo(mapLayers.traffic); + }); +} + +function updateMapNavWarnings(data) { + mapLayers.navwarnings.clearLayers(); + if (!data || data.error) { $('#cntNavWarn').textContent = '0'; return; } + var warnings = data.warnings || []; + var mapped = 0; + warnings.forEach(function(w) { + if (w.lat == null || w.lon == null) return; + mapped++; + var mk = L.marker([w.lat, w.lon], { + icon: L.divIcon({ className: 'mk-navwarn', iconSize: [6, 6], iconAnchor: [3, 3] }) + }); + var tip = 'NAVAREA ' + (w.navarea || '?') + ' ' + (w.id || ''); + mk.bindTooltip(tip, { className: 'mk-tip', direction: 'top', offset: [0, -4] }); + mk.on('click', function() { showDetail('navwarn', w); }); + mk.addTo(mapLayers.navwarnings); + }); + $('#cntNavWarn').textContent = mapped; +} + +function updateMapWebcams(data) { + mapLayers.webcams.clearLayers(); + if (!data || data.error) { $('#cntCams').textContent = '0'; return; } + var cams = data.cameras || []; + $('#cntCams').textContent = cams.length; + cams.forEach(function(cam) { + if (cam.lat == null || cam.lon == null) return; + var mk = L.marker([cam.lat, cam.lon], { + icon: L.divIcon({ className: 'mk-cam', iconSize: [7, 7], iconAnchor: [3.5, 3.5] }) + }); + mk.bindTooltip(esc(cam.title || 'Camera') + '
' + esc(cam.city || '') + '', { className: 'mk-tip', direction: 'top', offset: [0, -4] }); + mk.on('click', function() { showDetail('webcam', cam); }); + mk.addTo(mapLayers.webcams); + }); +} + function updateAlertBanner(data) { var el = $('#alertBanner'); if (!data || data.error || !data.alerts || !data.alerts.length) { @@ -2051,7 +2226,11 @@ function updateAll(data) { ['convergence', function() { updateMapConvergence(data.signal_convergence); }], ['nuclear', function() { updateMapNuclear(data.nuclear_monitor); }], ['infra', function() { updateMapInfra(data); }], - ['exposure', function() { updateMapExposure(data.population_exposure); }] + ['exposure', function() { updateMapExposure(data.population_exposure); }], + ['airtraffic', function() { updateMapAirTraffic(data.domestic_flights); }], + ['traffic', function() { updateMapTraffic(data.traffic_flow); }], + ['navwarnings', function() { updateMapNavWarnings(data.nav_warnings); }], + ['webcams', function() { updateMapWebcams(data.webcams); }] ]; mapUpdates.forEach(function(pair) { try { pair[1](); } catch(e) { console.warn('updateAll: ' + pair[0] + ' failed:', e); } diff --git a/src/world_intel_mcp/sources/aviation.py b/src/world_intel_mcp/sources/aviation.py index 66d0677..6dc0015 100644 --- a/src/world_intel_mcp/sources/aviation.py +++ b/src/world_intel_mcp/sources/aviation.py @@ -218,7 +218,11 @@ async def fetch_domestic_flights(fetcher: Fetcher) -> dict: country_counts: dict[str, int] = {} total = 0 - for s in states: + # Sample every Nth aircraft for map markers (keep payload <200KB) + sample_step = 10 + sampled: list[dict] = [] + + for idx, s in enumerate(states): if not isinstance(s, list) or len(s) < 15: continue if s[8]: # on_ground @@ -226,18 +230,30 @@ async def fetch_domestic_flights(fetcher: Fetcher) -> dict: total += 1 lat, lon = s[6], s[5] - callsign = s[1] + callsign = (s[1] or "").strip() origin = s[2] or "Unknown" region = _classify_region(lat, lon) + is_comm = _is_commercial(callsign) by_region[region]["count"] += 1 - if _is_commercial(callsign): + if is_comm: by_region[region]["commercial"] += 1 else: by_region[region]["general"] += 1 country_counts[origin] = country_counts.get(origin, 0) + 1 + # Sample for map display + if idx % sample_step == 0 and lat is not None and lon is not None: + sampled.append({ + "lat": round(lat, 2), + "lon": round(lon, 2), + "callsign": callsign or None, + "origin": origin, + "alt": round(s[7]) if s[7] else None, + "commercial": is_comm, + }) + # Remove empty regions by_region = {k: v for k, v in by_region.items() if v["count"] > 0} @@ -247,6 +263,7 @@ async def fetch_domestic_flights(fetcher: Fetcher) -> dict: "total_aircraft": total, "by_region": by_region, "busiest_origins": [{"country": c, "count": n} for c, n in busiest], + "positions": sampled, "source": "opensky-domestic", "timestamp": _utc_now_iso(), } diff --git a/src/world_intel_mcp/sources/maritime.py b/src/world_intel_mcp/sources/maritime.py index 9f6095f..106e0f5 100644 --- a/src/world_intel_mcp/sources/maritime.py +++ b/src/world_intel_mcp/sources/maritime.py @@ -6,7 +6,6 @@ No API key required. """ import logging -import os import re from datetime import datetime, timezone @@ -56,6 +55,29 @@ def _is_active(warning: dict) -> bool: return cancel is None or cancel == "" +_COORD_RE = re.compile( + r"(\d{1,3})-(\d{1,2}(?:\.\d+)?)\s*([NS])\s+" + r"(\d{1,3})-(\d{1,2}(?:\.\d+)?)\s*([EW])", +) + + +def _extract_coords(text: str) -> tuple[float | None, float | None]: + """Extract first lat/lon pair from NGA warning text. + + Parses formats like '22-16.65N 097-44.48W' into decimal degrees. + """ + m = _COORD_RE.search(text) + if not m: + return None, None + lat = int(m.group(1)) + float(m.group(2)) / 60 + if m.group(3) == "S": + lat = -lat + lon = int(m.group(4)) + float(m.group(5)) / 60 + if m.group(6) == "W": + lon = -lon + return round(lat, 4), round(lon, 4) + + def _parse_warning(warning: dict) -> dict: """Extract structured fields from a single NGA broadcast warning.""" msg_year = warning.get("msgYear", "") @@ -69,6 +91,8 @@ def _parse_warning(warning: dict) -> dict: if len(text) > _MAX_TEXT_LENGTH: text = text[:_MAX_TEXT_LENGTH] + "..." + lat, lon = _extract_coords(text) + return { "id": warning_id, "navarea": warning.get("navArea", ""), @@ -78,6 +102,8 @@ def _parse_warning(warning: dict) -> dict: "cancel_date": cancel_date if cancel_date else None, "text": text, "authority": warning.get("authority", "NGA"), + "lat": lat, + "lon": lon, }