@@ -1919,7 +1938,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;
@@ -1950,6 +1969,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) {
@@ -1960,7 +1987,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());
@@ -1976,24 +2003,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);
@@ -2822,9 +2873,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);
@@ -2832,10 +2887,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);
@@ -2844,7 +2901,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);
@@ -2943,20 +2999,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);
@@ -2969,7 +3029,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], {
@@ -3016,7 +3076,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);
@@ -3286,6 +3346,10 @@ async function toggleAircraft() {
if (acOn) await loadAircraft();
else acGroup = dropLayer(acGroup);
}
+function toggleAircraftMil() {
+ acMilOn = document.getElementById('lp-ac-mil-on').checked;
+ if (acOn) loadAircraft();
+}
async function loadAircraft() {
if (!map) return;
if (map.getZoom() <= 3) {
@@ -3297,8 +3361,17 @@ async function loadAircraft() {
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`);
const pts = await r.json();
if (req !== overlayReq.ac) return;
- acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], 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;
+ 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