// Live aircraft (ADS-B via OpenSky) as a BillboardCollection primitive. // Thousands of billboards rebuilt wholesale each poll; entities would crawl. // SPEC §6.3. // // Wave 2: when the clock is scrubbed off wall-clock now, instead of just // hiding, the layer REPLAYS actual recorded traffic from history/aircraft?t= // (served by serve.py from record.py's SQLite db). Replayed aircraft show with // an amber status. Where no recorder ran (prod), the endpoint 404s and the // layer degrades to the original hide-when-scrubbed behavior. export default function create(ctx) { const { viewer, CONFIG, lib, ui, Cesium } = ctx; const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection()); const glyph = lib.aircraftGlyph(); // shared canvas, tinted per-billboard let enabled = true; // HUD checkbox let isLive = true; // clock at wall-clock now let inFlight = false; // a live fetch is currently running let timer = null; // pending setTimeout handle let delay = CONFIG.aircraft.pollMs; // current cadence (grows on 429) // Replay state (only used when scrubbed off-live). let lastReplayT = null; // epoch_s of the last snapshot we requested (debounce) let replayInFlight = false; let pendingReplayT = null; // newest target requested while a fetch is in flight ui.addLayer('aircraft', 'Aircraft (live ADS-B)', true, (on) => { enabled = on; if (!on) { bc.show = false; return; } bc.show = true; if (isLive) kick(); // resume live polling else renderPlanes(lastPlanes); // scrubbed → restore the replayed frame }); // Military-callsign filter (default off): when on, only military aircraft // render. Military callsigns are always tinted, filter on or off. let militaryOnly = false; let lastPlanes = []; ui.addLayer('aircraft-mil', 'Military filter', false, (on) => { militaryOnly = on; renderPlanes(lastPlanes); // instant re-render of the current frame's planes }); const MIL_PREFIXES = CONFIG.aircraft.militaryPrefixes || []; function isMilitary(callsign) { if (!callsign) return false; const cs = callsign.toUpperCase(); return MIL_PREFIXES.some((p) => cs.startsWith(p)); } // ---- normalized plane shape: {icao24, callsign, country, lon, lat, alt, vel, track} ---- function stateToPlane(s) { return { icao24: s[0], callsign: (s[1] || '').trim(), country: s[2], lon: s[5], lat: s[6], alt: s[7], vel: s[9], track: s[10], }; } // Recorded snapshot plane: [icao24, callsign, lon, lat, baro_alt, track, vel]. function snapPlaneToPlane(a) { return { icao24: a[0], callsign: a[1], country: '—', lon: a[2], lat: a[3], alt: a[4], vel: a[6], track: a[5], }; } // Factored build loop — rebuilds the whole collection from a plane array. // Shared by the live poll and by replay. Returns the feed total (the aircraft // row shows the total; the Military-filter row shows "N military of M"), so // the aircraft count stays stable whether or not the filter is engaged. function renderPlanes(planes) { lastPlanes = planes; // remembered so toggling the filter re-renders instantly bc.removeAll(); let total = 0; let mil = 0; for (const p of planes) { if (p.lon == null || p.lat == null) continue; total++; const military = isMilitary(p.callsign); if (military) mil++; if (militaryOnly && !military) continue; bc.add({ position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt || 0), image: glyph, // Military callsigns tint red regardless of altitude band; civil use the band. color: lib.cz(military ? CONFIG.colors.aircraftMil : lib.altitudeColor(CONFIG, p.alt)), rotation: lib.headingToRotation(p.track), // lib negates — do NOT negate again scale: military ? 0.72 : 0.55, // bump military so it stands out id: { layer: 'aircraft', icao24: p.icao24, callsign: p.callsign, country: p.country, alt: p.alt, vel: p.vel, track: p.track, military, }, }); } ui.setStatus('aircraft-mil', `${mil} military of ${total}`, militaryOnly ? 'ok' : 'off'); return total; } // ---- live poll loop ------------------------------------------------------- // Poll only while enabled, live, and the tab is visible. function canPoll() { return enabled && isLive && !document.hidden; } function schedule(ms) { if (timer) clearTimeout(timer); timer = setTimeout(tick, ms); } function kick() { if (!canPoll() || timer || inFlight) return; schedule(0); } async function tick() { timer = null; if (!canPoll() || inFlight) return; inFlight = true; try { await poll(); } finally { inFlight = false; } if (canPoll()) schedule(delay); } function buildUrl() { let url = CONFIG.proxy.opensky; // RELATIVE — works at "/" and "/godsigh/" const bb = CONFIG.aircraft.bbox; if (bb) { const q = new URLSearchParams({ lamin: bb.lamin, lomin: bb.lomin, lamax: bb.lamax, lomax: bb.lomax, }); url = `${url}?${q.toString()}`; } return url; } async function poll() { let res; try { res = await fetch(buildUrl()); } catch (err) { ui.setStatus('aircraft', 'network error — retrying', 'warn'); return; } if (res.status === 429) { delay = delay * 4; // back off ui.setStatus('aircraft', 'rate-limited — anonymous OpenSky quota', 'warn'); return; } if (!res.ok) { ui.setStatus('aircraft', `HTTP ${res.status} — retrying`, 'warn'); return; } const remaining = res.headers.get('X-Rate-Limit-Remaining'); let data; try { data = await res.json(); } catch (err) { ui.setStatus('aircraft', 'bad response — retrying', 'warn'); return; } // The clock may have scrubbed off-live while this fetch was in flight — if so, // drop it so we never paint live traffic over the replay view (mirrors the // isLive guards in drainReplay). if (!isLive) return; const states = Array.isArray(data && data.states) ? data.states : []; const planes = []; for (const s of states) { if (!s) continue; if (s[5] == null || s[6] == null || s[8]) continue; // no position or on-ground planes.push(stateToPlane(s)); } const count = renderPlanes(planes); delay = CONFIG.aircraft.pollMs; // healthy response — reset cadence const stamp = data.time ? new Date(data.time * 1000).toLocaleTimeString() : '—'; const quota = remaining != null ? ` · quota ${remaining}` : ''; ui.setStatus('aircraft', `${count} aircraft · ${stamp}${quota}`, 'ok'); } // ---- replay (scrubbed off-live) ------------------------------------------- // Requests coalesce: while a history fetch is in flight, the newest requested // time is stashed and processed on completion — so a scrub that lands (even // paused, where onClockTick stops firing) still gets its final snapshot. function requestReplay(tSec) { pendingReplayT = tSec; drainReplay(); } // A transient history failure (network blip / 5xx) while the clock is PAUSED // would otherwise wedge — onClockTick won't fire again to retry. Clear the // debounce and re-request once shortly. (404 is intentional graceful-degrade // and is NOT retried here.) function scheduleReplayRetry(tSec) { lastReplayT = null; setTimeout(() => { if (!isLive && !replayInFlight) requestReplay(tSec); }, 4000); } async function drainReplay() { if (replayInFlight || pendingReplayT === null) return; const tSec = pendingReplayT; pendingReplayT = null; replayInFlight = true; lastReplayT = tSec; try { const res = await fetch(`history/aircraft?t=${tSec}`); if (isLive) return; // clock snapped back to live mid-fetch — discard if (res.status === 404) { bc.removeAll(); bc.show = false; ui.setStatus('aircraft', 'no history for this time', 'warn'); return; } if (!res.ok) { ui.setStatus('aircraft', `history HTTP ${res.status}`, 'warn'); scheduleReplayRetry(tSec); return; } let snap; try { snap = await res.json(); } catch (err) { ui.setStatus('aircraft', 'bad history response', 'warn'); scheduleReplayRetry(tSec); return; } if (isLive) return; const count = renderPlanes((snap.planes || []).map(snapPlaneToPlane)); bc.show = enabled; const stamp = snap.t ? new Date(snap.t * 1000).toLocaleTimeString() : '—'; // Amber = replayed traffic, not live (the LIVE/SCRUBBED chip already says SCRUBBED). ui.setStatus('aircraft', `replay · ${stamp} · ${count} aircraft`, 'warn'); } catch (err) { ui.setStatus('aircraft', 'replay fetch error', 'warn'); scheduleReplayRetry(tSec); } finally { replayInFlight = false; // A newer target arrived mid-fetch — process it now (covers a paused clock). if (pendingReplayT !== null && !isLive) drainReplay(); } } function onClockTick(currentTime, live) { isLive = live; if (isLive) { lastReplayT = null; // leaving replay — reset the debounce pendingReplayT = null; bc.show = enabled; if (enabled) kick(); // resume the live poll loop exactly as before return; } // Not live → replay recorded traffic (or gracefully hide if none recorded). if (!enabled) { bc.show = false; return; } const tSec = Math.round(Cesium.JulianDate.toDate(currentTime).getTime() / 1000); // Debounce on requested-time DISTANCE, not wall time: a scrub gesture fires // many ticks — only refetch when the target moves >60 s from the last snapshot. if (lastReplayT !== null && Math.abs(tSec - lastReplayT) <= 60) return; requestReplay(tSec); } function handlePick(picked) { if (picked?.id?.layer !== 'aircraft') return false; const id = picked.id; ui.showPickOverlay((id.military ? '✈⬤ ' : '✈ ') + (id.callsign || id.icao24), [ ['Class', id.military ? 'Military (callsign)' : 'Civil'], ['Country', id.country || '—'], ['Altitude', `${Math.round(id.alt || 0)} m / ${Math.round((id.alt || 0) * 3.28084)} ft`], ['Speed', `${Math.round((id.vel || 0) * 1.94384)} kt`], ['Heading', `${Math.round(id.track || 0)}°`], ]); return true; } function clearPick() { ui.hidePickOverlay(); } // Self-heal when the tab regains focus. A backgrounded tab kills the poll // loop (canPoll() is false, so tick() returns without rescheduling); if the // clock is also paused, onClockTick never fires again to restart it (its // currentTime is frozen), so the feed would stall permanently. kick() is // idempotent (guarded by timer/inFlight/canPoll), so this is safe. document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); }); // One immediate fetch on init. kick(); return { id: 'aircraft', onClockTick, handlePick, clearPick }; }