GODSIGH/js/layers/radius.js
jing 60ceb4a261 wave3.1: ADS-B OSINT layers (emergency squawks, rare-type hunter, shadow, radius focus)
All FREE via adsb.lol. New generic pieces:
- serve.py proxy/adsb/<path>: regex-allowlisted (SSRF-safe) adsb.lol v2 proxy
  with per-path 120s cache + upstream call-spacing (adsb.lol 429s on bursts;
  reservation slots space call *starts* without serializing network time),
  12s fail-fast timeout, stale-on-error
- js/layers/adsb-layer.js: generic {ac:[...]} billboard factory (parallel
  allSettled fetch, live/scrub/visibility gating, optional client-side filter)
- js/adsb-registry.js: emergency (sqk 7700/7600/7500, default-on, alerts red),
  rare-type hunter (filters the shared mil feed by ICAO type — 1 cached call,
  no per-type rate-limit), shadow (LADD+PIA, purple/pink)
- js/layers/radius.js: camera-driven 'all traffic within 250nm of view'
- verified live: all four render, worst-case simultaneous-enable ok, 0 errors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:19:21 +10:00

107 lines
3.8 KiB
JavaScript

// Radius focus (Wave 3.1) — all traffic within 250 nm of where you're looking,
// via adsb.lol's free lat/lon/dist query (proxy/adsb/lat/.../lon/.../dist/250).
// Camera-driven: refetches when you stop panning/zooming, plus a slow refresh
// for freshness. Most useful zoomed into a region. Live-only; off by default.
export default function create(ctx) {
const { viewer, Cesium, lib, ui } = ctx;
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
const glyph = lib.aircraftGlyph();
const DIST_NM = 250; // adsb.lol free radius cap
let enabled = false;
let isLive = true;
let inFlight = false;
let debounce = null;
let poller = null;
let lastKey = null;
bc.show = false;
ui.addLayer('radius', `Traffic near view (${DIST_NM}nm)`, false, (on) => {
enabled = on;
bc.show = on && isLive;
if (on && isLive) { refresh(); ensurePoller(); }
else { clearInterval(poller); poller = null; ui.setStatus('radius', on ? 'locating…' : '', on ? 'warn' : 'ok'); }
});
viewer.camera.percentageChanged = 0.25; // fire camera.changed a bit more eagerly
viewer.camera.changed.addEventListener(() => {
if (!enabled || !isLive) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(refresh, 1800); // refetch after panning stops
});
function ensurePoller() {
if (poller) return;
poller = setInterval(() => { if (enabled && isLive && !document.hidden) refresh(); }, 60000);
}
// Center = camera nadir point, rounded (also improves proxy cache hits).
function center() {
const c = viewer.camera.positionCartographic;
return [
+Cesium.Math.toDegrees(c.latitude).toFixed(2),
+Cesium.Math.toDegrees(c.longitude).toFixed(2),
];
}
async function refresh() {
if (!enabled || !isLive || document.hidden || inFlight) return;
const [lat, lon] = center();
if (!isFinite(lat) || !isFinite(lon)) return;
const key = `${lat}/${lon}`;
lastKey = key;
inFlight = true;
try {
const res = await fetch(`proxy/adsb/lat/${lat}/lon/${lon}/dist/${DIST_NM}`);
if (key !== lastKey) return; // camera moved again mid-fetch — stale
if (!res.ok) { ui.setStatus('radius', `HTTP ${res.status}`, 'warn'); return; }
const data = await res.json();
const list = Array.isArray(data.ac) ? data.ac : [];
bc.removeAll();
let n = 0;
for (const a of list) {
if (a.lat == null || a.lon == null) continue;
const altFt = typeof a.alt_baro === 'number' ? a.alt_baro : 0;
bc.add({
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFt * 0.3048),
image: glyph,
color: lib.cz('#7bff9d'),
rotation: lib.headingToRotation(a.track),
scale: 0.7,
id: { layer: 'radius', hex: a.hex, flight: (a.flight || '').trim(), t: a.t,
reg: a.r, altFt, gs: a.gs, track: a.track },
});
n++;
}
ui.setStatus('radius', `${n} within ${DIST_NM}nm of view`, 'ok');
} catch {
ui.setStatus('radius', 'network error', 'warn');
} finally {
inFlight = false;
}
}
function onClockTick(currentTime, live) {
isLive = live;
if (!isLive) { bc.show = false; return; }
bc.show = enabled;
if (enabled) { refresh(); ensurePoller(); }
}
function handlePick(picked) {
if (picked?.id?.layer !== 'radius') return false;
const id = picked.id;
ui.showPickOverlay('✈ ' + (id.flight || id.hex || '—'), [
['Type', id.t || '—'], ['Reg', id.reg || '—'],
['Altitude', `${Math.round(id.altFt || 0)} ft`], ['Speed', `${Math.round(id.gs || 0)} kt`],
]);
return true;
}
function clearPick() { ui.hidePickOverlay(); }
document.addEventListener('visibilitychange', () => { if (!document.hidden && enabled && isLive) refresh(); });
return { id: 'radius', onClockTick, handlePick, clearPick };
}