GODSIGH/js/layers/aircraft.js
jing 767884146d wave2 phase 4: historical record & replay (true time-travel)
Scrubbing off-live now REPLAYS actual recorded traffic instead of just hiding
aircraft (orbits were already computable in the past; live traffic was not).

- record.py (new, dev-only daemon): every 180s fetches through the local proxy
  (so the shared cache dedupes quota — never hits OpenSky directly), reduces to
  airborne essentials, zlib-compresses into SQLite data/history.db (WAL), keeps
  72h, prunes + checkpoints hourly. Wired into John's ~/.jobs heartbeat.
- serve.py: GET history/aircraft?t=<epoch_s> returns the nearest snapshot within
  ±10 min (read-only per-request sqlite connection — WAL means never locks the
  recorder), else a JSON 404.
- aircraft.js: onClockTick drives replay when not-live. Build loop factored into
  renderPlanes() shared by live + replay. Replayed aircraft show amber ("replay
  · <time> · N aircraft"); 404 → "no history for this time" + hide (this is the
  prod-without-recorder path too). Requests coalesce so a scrub landing mid-fetch
  still resolves to its final snapshot; debounced on requested-time distance.
- data/ gitignored (recorder db is local-only, never committed or deployed).

Verified: history endpoint hit/404/400; frontend replay across -1h/-2h/-3h maps
to distinct snapshots, 404 transitions, live-resume; recorder daemon writes
heartbeat + snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:30:17 +10:00

256 lines
8.7 KiB
JavaScript

// 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;
bc.show = on && isLive;
if (on && isLive) kick();
});
// ---- 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.
function renderPlanes(planes) {
bc.removeAll();
let count = 0;
for (const p of planes) {
if (p.lon == null || p.lat == null) continue;
bc.add({
position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt || 0),
image: glyph,
color: lib.cz(lib.altitudeColor(CONFIG, p.alt)),
rotation: lib.headingToRotation(p.track), // lib negates — do NOT negate again
scale: 0.55,
id: {
layer: 'aircraft',
icao24: p.icao24,
callsign: p.callsign,
country: p.country,
alt: p.alt,
vel: p.vel,
track: p.track,
},
});
count++;
}
return count;
}
// ---- 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;
}
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();
}
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');
return;
}
let snap;
try {
snap = await res.json();
} catch (err) {
ui.setStatus('aircraft', 'bad history response', 'warn');
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');
} 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.callsign || id.icao24), [
['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 };
}