@@ -1991,7 +2010,7 @@ let sentinelItemId = null, sentinelBounds = null;
let wxAlertsGroup = null, wxAlertsOn = true;
let perimGroup = null, perimOn = true;
let incidentsGroup = null, incidentsOn = false;
-let acGroup = null, acOn = true;
+let acGroup = null, acOn = true, acMilOn = false, acMilSupported = false;
let trainsGroup = null, trainsOn = true;
let vesselsGroup = null, vesselsOn = false;
let stormsGroup = null, stormsOn = true;
@@ -2024,6 +2043,14 @@ function sendLiveViewport() {
if (!liveWs || liveWs.readyState !== 1 || !map) return;
liveWs.send(JSON.stringify({ type: 'viewport', bbox: currentBBox() }));
}
+function dropLivePoint(group, id) {
+ if (!group || !group._osintById || id == null) return;
+ const key = String(id);
+ const m = group._osintById.get(key);
+ if (!m) return;
+ group.removeLayer(m);
+ group._osintById.delete(key);
+}
function upsertLivePoint(group, p, colorFn, feed) {
if (!map || !p || p.id == null || p.lat == null || p.lon == null) return group;
if (!group || !map.hasLayer(group) || !group._osintById) {
@@ -2034,7 +2061,7 @@ function upsertLivePoint(group, p, colorFn, feed) {
let m = group._osintById.get(id);
if (m) {
m.setLatLng([p.lat, p.lon]);
- if (feed) m.setIcon(feedIcon(feed, col, p.heading));
+ if (feed) m.setIcon(feedIcon(feed, col, p.heading, feedPulse(feed, p)));
else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
} else {
m = makePointMarker(p, colorFn, feed, pointCanvas());
@@ -2050,24 +2077,48 @@ function applyLiveMarker(kind, p) {
return sog > 0.5 ? '#2dd4bf' : '#64748b';
}, 'vessel');
} else if (kind === 'adsb' && acOn && map && map.getZoom() > 3 && !dvrTs) {
+ if (!acVisible(p)) {
+ dropLivePoint(acGroup, p.id);
+ return;
+ }
acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac');
} else if (kind === 'geofence_alert') {
showGeofenceToast(p);
} else if (kind === 'fire_aircraft') {
firefighterHex.add(String(p.aircraft_hex || ''));
if (acOn && p.aircraft_lat != null) {
- acGroup = upsertLivePoint(acGroup, {
+ const row = {
id: p.aircraft_hex, lat: p.aircraft_lat, lon: p.aircraft_lon,
label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true, hex: p.aircraft_hex, src: 'adsb.lol' },
- }, q => acColor(q), 'ac');
+ };
+ if (!acVisible(row)) dropLivePoint(acGroup, row.id);
+ else acGroup = upsertLivePoint(acGroup, row, q => acColor(q), 'ac');
}
}
}
+const EMERG_SQUAWK = new Set(['7700', '7600', '7500']);
+function acIsEmergency(p) {
+ const extra = (p && p.extra) || {};
+ const em = String(extra.emergency || '').toLowerCase();
+ if (em && em !== 'none') return true;
+ const sq = String(extra.squawk || '').replace(/\s/g, '');
+ return EMERG_SQUAWK.has(sq);
+}
+function acVisible(p) {
+ if (!acMilOn) return true;
+ return ((p && p.extra) || {}).role === 'military';
+}
+function noteMilSupport(pts) {
+ if (acMilSupported) return;
+ if (!Array.isArray(pts) || !pts.some(q => q && q.extra && q.extra.role)) return;
+ acMilSupported = true;
+ const row = document.getElementById('lp-ac-mil-row');
+ if (row) row.hidden = false;
+}
function acColor(p) {
const extra = p.extra || {};
const t = String(extra.type || '').toUpperCase();
- const em = String(extra.emergency || '').toLowerCase();
- if (em && em !== 'none') return '#ff5d5d';
+ if (acIsEmergency(p)) return '#ff5d5d';
if (extra.firefighter || firefighterHex.has(String(p.id)) || FF_ICAO.has(t)) return '#fb923c';
if (extra.role === 'military') return '#f472b6';
return altColor(extra.alt_baro);
@@ -2910,9 +2961,13 @@ function pointPopup(p) {
const extra = p.extra || {};
const src = extra.src || '';
const role = extra.firefighter ? 'firefighter' : extra.role;
- const badge = role
- ? `
${esc(String(role).toUpperCase())}`
- : '';
+ let badge = '';
+ if (src === 'adsb.lol' && acIsEmergency(p)) {
+ badge += '
EMERGENCY';
+ }
+ if (role) {
+ badge += `
${esc(String(role).toUpperCase())}`;
+ }
const rows = [];
const add = (k, v) => {
const val = _popVal(v);
@@ -2920,10 +2975,12 @@ function pointPopup(p) {
rows.push(`
| ${esc(k)} | ${esc(val)} |
`);
};
if (src === 'adsb.lol') {
+ add('callsign', p.label);
+ add('hex', extra.hex);
+ add('registration', extra.reg);
add('type', extra.type);
add('aircraft', extra.desc);
add('operator', extra.ownOp);
- add('reg', extra.reg);
const alt = extra.alt_baro;
add('alt', alt != null ? `${alt} ft` : null);
add('vs', extra.vs != null ? `${extra.vs} fpm` : null);
@@ -2932,7 +2989,6 @@ function pointPopup(p) {
add('squawk', extra.squawk);
add('emergency', extra.emergency);
add('class', extra.emitter);
- add('hex', extra.hex);
} else if (src === 'aisstream') {
add('kind', extra.kind);
add('flag', extra.country);
@@ -3031,20 +3087,24 @@ function sanitizeColor(c, fallback) {
if (/^rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(,\s*[\d.]+\s*)?\)$/.test(s)) return s;
return fallback;
}
-function feedIcon(feed, color, heading) {
+function feedPulse(feed, p) {
+ return feed === 'ac' && acIsEmergency(p);
+}
+function feedIcon(feed, color, heading, pulse) {
// Normalize heading to [0,360) integer so the cache stays bounded.
// Missing/empty/NaN heading -> -1 sentinel -> glyph rendered upright.
const hnum = Number(heading);
const h = (heading === null || heading === '' || heading === undefined || !Number.isFinite(hnum))
? -1
: (Math.round(hnum % 360) + 360) % 360;
- const key = `${feed}|${color}|${h}`;
+ const key = `${feed}|${color}|${h}|${pulse ? 1 : 0}`;
let ic = feedIconCache.get(key);
if (!ic) {
const rot = h >= 0 ? `transform:rotate(${h}deg);` : '';
+ const pulseCls = pulse ? ' hdg-emerg' : '';
ic = L.divIcon({
className: '',
- html: `
${FEED_GLYPHS[feed]}`,
+ html: `
${FEED_GLYPHS[feed]}`,
iconSize: [26, 26], iconAnchor: [13, 13],
});
feedIconCache.set(key, ic);
@@ -3057,7 +3117,7 @@ function makePointMarker(p, colorFn, feed, renderer) {
let m;
if (feed) {
const heading = Number(p.heading);
- const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading);
+ const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading, feedPulse(feed, p));
m = L.marker([p.lat, p.lon], { icon });
} else {
m = L.circleMarker([p.lat, p.lon], {
@@ -3104,7 +3164,7 @@ function renderPoints(existing, points, colorFn, cluster, feed) {
if (m) {
m.setLatLng([p.lat, p.lon]);
m._osintP = p;
- if (feed) m.setIcon(feedIcon(feed, col, p.heading));
+ if (feed) m.setIcon(feedIcon(feed, col, p.heading, feedPulse(feed, p)));
else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
} else {
const nm = makePointMarker(p, colorFn, feed, renderer);
@@ -3376,6 +3436,10 @@ async function toggleAircraft() {
if (acOn) await loadAircraft();
else { acGroup = dropLayer(acGroup); lastAircraft = []; }
}
+function toggleAircraftMil() {
+ acMilOn = document.getElementById('lp-ac-mil-on').checked;
+ if (acOn) loadAircraft();
+}
async function loadAircraft() {
if (!map) return;
if (map.getZoom() <= 3) {
@@ -3387,9 +3451,18 @@ async function loadAircraft() {
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`);
const pts = await r.json();
if (req !== overlayReq.ac) return;
- lastAircraft = Array.isArray(pts) ? pts : [];
- acGroup = renderPoints(acGroup, lastAircraft, p => acColor(p), true, 'ac');
- document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
+ const all = Array.isArray(pts) ? pts : [];
+ noteMilSupport(all);
+ const shown = acMilOn ? all.filter(acVisible) : all;
+ lastAircraft = shown;
+ acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac');
+ document.getElementById('lp-ac-count').textContent = shown.length.toLocaleString();
+ const milEl = document.getElementById('lp-ac-mil-count');
+ if (milEl) {
+ milEl.textContent = acMilOn
+ ? shown.length.toLocaleString()
+ : String(all.filter(q => ((q.extra || {}).role === 'military')).length);
+ }
addExtraAttrib('
ADSB.lol ODbL');
addExtraAttrib('
Photo © planespotters.net');
} catch (e) {
diff --git a/tests/test_aircraft_popup_frontend.py b/tests/test_aircraft_popup_frontend.py
new file mode 100644
index 0000000..98c1f09
--- /dev/null
+++ b/tests/test_aircraft_popup_frontend.py
@@ -0,0 +1,57 @@
+"""Aircraft popup enrichment + emergency/MIL layer contract (static HTML)."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+HTML = (ROOT / "app/static/index.html").read_text()
+
+
+def _fn(name: str, nxt: str) -> str:
+ return HTML.split(f"function {name}", 1)[1].split(f"function {nxt}", 1)[0]
+
+
+def test_popup_has_required_adsb_fields_and_photo():
+ js = _fn("pointPopup", "loadPlanePhoto")
+ for field in ("callsign", "hex", "registration", "type", "alt", "gs", "squawk"):
+ assert f"add('{field}'" in js
+ assert "class=\"ps-photo\"" in js or "class='ps-photo'" in js
+ assert "wikipedia" not in js.lower()
+ assert "ceo" not in js.lower()
+
+
+def test_emergency_badge_and_squawk_codes():
+ assert "role-badge emergency" in HTML
+ assert "hdg-emerg" in HTML
+ assert "EMERG_SQUAWK" in HTML
+ assert "['7700', '7600', '7500']" in HTML
+ emerg = HTML.split("function acIsEmergency", 1)[1].split("function acVisible", 1)[0]
+ assert "EMERG_SQUAWK.has(sq)" in emerg
+ color = HTML.split("function acColor", 1)[1].split("function connectLiveWs", 1)[0]
+ assert "acIsEmergency(p)" in color
+ assert "#ff5d5d" in color
+
+
+def test_mil_toggle_hidden_until_role_flag_and_never_hits_adsb_lol():
+ assert 'id="lp-ac-mil-row"' in HTML
+ assert 'id="lp-ac-mil-on"' in HTML
+ row = HTML.split('id="lp-ac-mil-row"', 1)[1].split(">", 1)[0]
+ assert "hidden" in row
+ on = HTML.split('id="lp-ac-mil-on"', 1)[1].split(">", 1)[0]
+ assert "checked" not in on
+ load = HTML.split("async function loadAircraft", 1)[1].split("async function toggleTrains", 1)[0]
+ assert "/api/aircraft?bbox=" in load
+ assert "api.adsb.lol" not in load
+ assert "noteMilSupport" in load
+ assert "acMilOn" in load
+ note = HTML.split("function noteMilSupport", 1)[1].split("function acColor", 1)[0]
+ assert "extra.role" in note
+ assert "lp-ac-mil-row" in note
+ assert "hidden = false" in note
+
+
+def test_planespotters_lazy_photo_still_wired():
+ assert "function loadPlanePhoto" in HTML
+ assert "/api/aircraft/photo?" in HTML
+ assert "map.on('popupopen', (e) => { loadPlanePhoto(e.popup); });" in HTML
diff --git a/tests/test_conflicts.py b/tests/test_conflicts.py
new file mode 100644
index 0000000..eb60d68
--- /dev/null
+++ b/tests/test_conflicts.py
@@ -0,0 +1,132 @@
+"""GET /api/conflicts — curated conflict-zone catalog + event-count roll-up.
+
+No outbound HTTP: event counts come from geocoded rows already (or not) in the
+DB, and the API tests monkeypatch ``main._fetch_geocoded_points`` so no database
+is required for the contract checks.
+"""
+
+from datetime import datetime, timezone
+
+import httpx
+
+from conflicts import SEVERITIES, conflict_zones, zone_event_stats
+from live_layers import overlay_catalog
+from main import app
+
+BASE = "http://test"
+
+
+def _get(path: str, monkeypatch=None, points=None) -> httpx.Response:
+ import asyncio
+
+ async def run() -> httpx.Response:
+ if monkeypatch is not None:
+ async def fake():
+ return points or []
+
+ monkeypatch.setattr("main._fetch_geocoded_points", fake)
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
+ return await client.get(path)
+
+ return asyncio.run(run())
+
+
+# ── Catalog shape ──────────────────────────────────────────────────────
+
+
+def test_catalog_length():
+ zones = conflict_zones()
+ assert len(zones) == 13
+
+
+def test_catalog_severity_enum():
+ zones = conflict_zones()
+ sevs = {z["severity"] for z in zones}
+ assert sevs.issubset(SEVERITIES)
+ # All three tiers are represented.
+ assert sevs == SEVERITIES
+
+
+def test_catalog_fields_factual_and_complete():
+ zones = conflict_zones()
+ ids = [z["id"] for z in zones]
+ assert len(set(ids)) == len(ids) # unique ids
+ for z in zones:
+ assert z["label"]
+ assert z["description"].strip()
+ assert -90.0 <= z["lat"] <= 90.0
+ assert -180.0 <= z["lon"] <= 180.0
+ # internal-only bbox is well-formed: (min_lat, min_lon, max_lat, max_lon)
+ min_lat, min_lon, max_lat, max_lon = z["bbox"]
+ assert min_lat <= max_lat and min_lon <= max_lon
+ assert min_lat <= z["lat"] <= max_lat and min_lon <= z["lon"] <= max_lon
+
+
+def test_overlay_catalog_has_conflicts():
+ entry = overlay_catalog()["conflicts"]
+ assert entry["kind"] == "points"
+ assert entry["endpoint"] == "/api/conflicts"
+
+
+# ── Pure counting ──────────────────────────────────────────────────────
+
+TS1 = datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc)
+TS2 = datetime(2026, 8, 30, 13, 0, tzinfo=timezone.utc)
+
+
+def test_zone_event_stats_counts_and_picks_latest():
+ bbox = (40.0, 20.0, 52.0, 40.0) # roughly Ukraine
+ points = [
+ (50.45, 30.52, TS1), # inside
+ (48.0, 25.0, TS2), # inside, later
+ (0.0, -60.0, TS1), # outside
+ (15.0, 45.0, TS2), # outside (lat ok, lon out)
+ ]
+ count, latest = zone_event_stats(points, bbox)
+ assert count == 2
+ assert latest == TS2
+
+
+def test_zone_event_stats_empty_bbox():
+ count, latest = zone_event_stats([], (0.0, 0.0, 1.0, 1.0))
+ assert count == 0
+ assert latest is None
+
+
+# ── API contract (mocked map items, no DB) ─────────────────────────────
+
+
+def test_conflicts_returns_catalog_with_mocked_counts(monkeypatch):
+ points = [
+ (50.45, 30.52, TS1), # Ukraine
+ (25.03, 121.56, TS2), # Taiwan Strait
+ (0.0, -60.0, TS1), # nowhere
+ ]
+ resp = _get("/api/conflicts", monkeypatch=monkeypatch, points=points)
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "zones" in body and "timestamp" in body
+ by_id = {z["id"]: z for z in body["zones"]}
+ assert len(body["zones"]) == 13
+
+ zone = by_id["ukraine"]
+ assert zone["eventCount"] == 1
+ assert zone["lastUpdated"] == TS1.isoformat().replace("+00:00", "Z")
+ assert zone["severity"] == "war"
+
+ assert by_id["taiwan_strait"]["eventCount"] == 1
+ assert by_id["gaza"]["eventCount"] == 0
+ # exact per-zone key contract the frontend consumes
+ assert set(zone.keys()) == {
+ "id", "label", "severity", "lat", "lon",
+ "description", "eventCount", "lastUpdated",
+ }
+
+
+def test_conflicts_empty_db_yields_zero_counts(monkeypatch):
+ resp = _get("/api/conflicts", monkeypatch=monkeypatch, points=[])
+ assert resp.status_code == 200
+ body = resp.json()
+ assert all(z["eventCount"] == 0 for z in body["zones"])
+ assert all(z["lastUpdated"] is None for z in body["zones"])
diff --git a/tests/test_live_layers.py b/tests/test_live_layers.py
index ae0f68e..a646deb 100644
--- a/tests/test_live_layers.py
+++ b/tests/test_live_layers.py
@@ -1,5 +1,7 @@
"""Unit tests for live map-layer mappers (aircraft, trains, AIS, WFIGS, Caltrans)."""
+import json
+
from live_layers import (
MARKER_FIELDS,
bbox_center_radius_nm,
@@ -25,7 +27,7 @@ from live_layers import (
_wfigs_params,
)
-from camera_scraper import parse_caltrans_json
+from camera_scraper import parse_caltrans_json, parse_mdot_json
def test_parse_bbox_and_radius_clamps_to_150_nm():
@@ -257,6 +259,65 @@ def test_parse_caltrans_skips_oos_and_maps_jpeg_hls():
assert "rtsp://" not in cam["snapshot_url"].lower()
+def test_parse_mdot_extracts_html_fields_and_bbox_filters():
+ rows = [
+ # In-bbox, full fields.
+ {
+ "route": "11 Mile",
+ "county": 'Wayne County
Go to',
+ "location": " @ Mound NB",
+ "direction": "Traffic closest to camera is traveling north.",
+ "image": '

',
+ },
+ # Out of bbox (lat 50) → drop.
+ {
+ "route": "Far",
+ "county": 'Nowhere
Go to',
+ "location": "",
+ "image": '

',
+ },
+ # Missing coordinates → drop.
+ {
+ "route": "NoCoords",
+ "county": 'Somewhere
Go to',
+ "location": "",
+ "image": '

',
+ },
+ # Missing image → drop.
+ {
+ "route": "NoImage",
+ "county": 'Kent
Go to',
+ "location": " @ Division",
+ "image": "",
+ },
+ # RTSP image src → drop.
+ {
+ "route": "Rtsp",
+ "county": 'Wayne
Go to',
+ "location": "",
+ "image": '

',
+ },
+ ]
+ cams = parse_mdot_json(json.dumps(rows), "mdotjboss.state.mi.us")
+ assert len(cams) == 1
+ cam = cams[0]
+ assert cam["discovery_source"] == "mdot"
+ assert cam["location_lat"] == 42.491304
+ assert cam["location_lon"] == -83.04479
+ assert cam["snapshot_url"] == "https://micamerasimages.net/thumbs/semtoc_cam_253.flv.jpg?item=1"
+ assert cam["source_url"] == "https://mdotjboss.state.mi.us/MiDrive/camera/1129"
+ assert cam["device_type"] == "http"
+ assert cam["vendor"] == "MDOT"
+ assert "11 Mile @ Mound NB" in cam["location_name"]
+ assert "Wayne County" in cam["location_name"]
+
+
+def test_parse_mdot_handles_malformed_payload():
+ assert parse_mdot_json("not json", "mdot") == []
+ assert parse_mdot_json('{"not": "a list"}', "mdot") == []
+ assert parse_mdot_json("[]", "mdot") == []
+
+
def test_quantize_bbox_stable_under_jitter():
a = quantize_bbox(*parse_bbox("-78.7912,35.7711,-78.6101,35.9102"))
b = quantize_bbox(*parse_bbox("-78.7900,35.7700,-78.6110,35.9090"))