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
+
+
+
+
@@ -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,
}