Applied 4 confirmed findings from the multi-agent review (5th was a documented deliberate deviation): - aircraft: self-healing visibilitychange listener — the live feed no longer stalls permanently after tab hide->show while the clock is paused (MAJOR) - SPEC §9: nginx proxy_pass with $is_args$args needs a resolver or prod 502s while nginx -t still passes — documented + hardened the deploy step (MAJOR) - ships: live-AIS map now evicts true least-recently-updated (move-to-end), not oldest-inserted - ships: corrected inverted Hormuz lane direction labels (tracks with decreasing longitude sail west/inbound, not east) - README: full data-source + attribution table, real-vs-DEMO breakdown, architecture, roadmap Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
4.8 KiB
JavaScript
166 lines
4.8 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.
|
|
|
|
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 fetch is currently running
|
|
let timer = null; // pending setTimeout handle
|
|
let delay = CONFIG.aircraft.pollMs; // current cadence (grows on 429)
|
|
|
|
ui.addLayer('aircraft', 'Aircraft (live ADS-B)', true, (on) => {
|
|
enabled = on;
|
|
bc.show = on && isLive;
|
|
if (on) kick();
|
|
});
|
|
|
|
// 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 : [];
|
|
|
|
bc.removeAll();
|
|
let count = 0;
|
|
for (const s of states) {
|
|
if (!s) continue;
|
|
const lon = s[5];
|
|
const lat = s[6];
|
|
const onGround = s[8];
|
|
if (lon == null || lat == null || onGround) continue;
|
|
const alt = s[7];
|
|
const track = s[10];
|
|
bc.add({
|
|
position: Cesium.Cartesian3.fromDegrees(lon, lat, alt || 0),
|
|
image: glyph,
|
|
color: lib.cz(lib.altitudeColor(CONFIG, alt)),
|
|
rotation: lib.headingToRotation(track), // lib negates — do NOT negate again
|
|
scale: 0.55,
|
|
id: {
|
|
layer: 'aircraft',
|
|
icao24: s[0],
|
|
callsign: (s[1] || '').trim(),
|
|
country: s[2],
|
|
alt,
|
|
vel: s[9],
|
|
track,
|
|
},
|
|
});
|
|
count++;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
function onClockTick(currentTime, live) {
|
|
isLive = live;
|
|
if (!isLive) {
|
|
bc.show = false; // pause: hide + skip fetches
|
|
return;
|
|
}
|
|
bc.show = enabled;
|
|
if (enabled) kick();
|
|
}
|
|
|
|
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 };
|
|
}
|