Every marker used disableDepthTestDistance: Infinity, so points/billboards on the FAR side of the Earth drew on top of the near hemisphere instead of being occluded — reading as 'dots stuck, not moving with the globe' when you rotate. Changed to 50000 (50km) across all layers: the globe now occludes far-side markers, while you can still zoom right up to one without it clipping into the surface. Also made the billboard layers (aircraft/military/adsb/radius) explicit for consistent behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
4.6 KiB
JavaScript
119 lines
4.6 KiB
JavaScript
// Generic adsb.lol query-layer factory (free OSINT feeds). Powers the emergency
|
|
// squawk, rare-type hunter, and shadow-aircraft layers — each is a spec that
|
|
// names one or more adsb.lol v2 endpoints (proxy/adsb/<path>), merged by hex and
|
|
// drawn as a BillboardCollection primitive with a click overlay. Live-only
|
|
// (paused when scrubbed off-now or the tab is hidden), like the aircraft layer.
|
|
//
|
|
// spec: { id, name, category, defaultOn, pollMs,
|
|
// endpoints: ['sqk/7700', 'type/U2', ...], // fetched sequentially (anti-burst)
|
|
// color(a) -> hex, scale(a)|number, label(a) -> string|null,
|
|
// describe(a) -> [[k,v],...], status(n) -> string, emptyStatus? -> string }
|
|
|
|
export function createAdsbLayer(ctx, spec) {
|
|
const { viewer, Cesium, lib, ui } = ctx;
|
|
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
|
|
const glyph = lib.aircraftGlyph();
|
|
const pollMs = spec.pollMs || 90000;
|
|
|
|
let enabled = spec.defaultOn !== false;
|
|
let isLive = true;
|
|
let inFlight = false;
|
|
let timer = null;
|
|
let lastOk = true;
|
|
|
|
bc.show = enabled;
|
|
ui.addLayer(spec.id, spec.name, enabled, (on) => {
|
|
enabled = on;
|
|
bc.show = on && isLive;
|
|
if (on && isLive) kick();
|
|
}, spec.category);
|
|
ui.setStatus(spec.id, 'loading…', 'warn');
|
|
|
|
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; }
|
|
// On a total failure (e.g. adsb.lol rate-limited the whole batch), retry
|
|
// soon instead of waiting the full interval.
|
|
if (canPoll()) schedule(lastOk ? pollMs : 9000);
|
|
}
|
|
|
|
const altFeet = (a) => (typeof a.alt_baro === 'number' ? a.alt_baro : 0);
|
|
|
|
async function poll() {
|
|
// Fetch endpoints in PARALLEL and merge by hex. The proxy already spaces the
|
|
// upstream calls (rate-limit politeness), so parallel is safe here — and it
|
|
// means one slow/hung endpoint (adsb.lol can be flaky) never stalls the rest.
|
|
const byHex = new Map();
|
|
let anyOk = false;
|
|
const results = await Promise.allSettled(
|
|
spec.endpoints.map((ep) => fetch(`proxy/adsb/${ep}`).then((r) => {
|
|
if (!r.ok) throw new Error(String(r.status));
|
|
return r.json().then((d) => ({ ep, d }));
|
|
}))
|
|
);
|
|
for (const r of results) {
|
|
if (r.status !== 'fulfilled') continue;
|
|
anyOk = true;
|
|
const { ep, d } = r.value;
|
|
for (const a of (Array.isArray(d.ac) ? d.ac : [])) {
|
|
if (a.lat == null || a.lon == null || byHex.has(a.hex)) continue;
|
|
a._src = ep;
|
|
byHex.set(a.hex, a);
|
|
}
|
|
}
|
|
lastOk = anyOk;
|
|
if (!anyOk) { ui.setStatus(spec.id, 'busy — retrying shortly', 'warn'); return; }
|
|
|
|
// Optional client-side filter (e.g. rare-types filters the shared mil feed
|
|
// by ICAO type — one cached call, no per-type rate-limit risk).
|
|
let list = [...byHex.values()];
|
|
if (spec.filter) list = list.filter(spec.filter);
|
|
|
|
bc.removeAll();
|
|
for (const a of list) {
|
|
bc.add({
|
|
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFeet(a) * 0.3048),
|
|
image: glyph,
|
|
color: lib.cz(spec.color(a)),
|
|
rotation: lib.headingToRotation(a.track),
|
|
scale: typeof spec.scale === 'function' ? spec.scale(a) : (spec.scale || 0.8),
|
|
disableDepthTestDistance: 50000, // occlude when behind the globe (far side)
|
|
id: {
|
|
layer: spec.id, hex: a.hex, flight: (a.flight || '').trim(),
|
|
t: a.t, reg: a.r, altFt: altFeet(a), gs: a.gs, track: a.track,
|
|
squawk: a.squawk, src: a._src,
|
|
},
|
|
});
|
|
}
|
|
const n = list.length;
|
|
const txt = n === 0 && spec.emptyStatus ? spec.emptyStatus() : spec.status(n);
|
|
ui.setStatus(spec.id, txt, n > 0 && spec.alertOnHits ? 'err' : 'ok');
|
|
}
|
|
|
|
function onClockTick(currentTime, live) {
|
|
isLive = live;
|
|
if (!isLive) { bc.show = false; return; }
|
|
bc.show = enabled;
|
|
if (enabled) kick();
|
|
}
|
|
|
|
function handlePick(picked) {
|
|
if (picked?.id?.layer !== spec.id) return false;
|
|
ui.showPickOverlay(spec.pickTitle ? spec.pickTitle(picked.id) : (picked.id.flight || picked.id.hex || '—'),
|
|
spec.describe(picked.id));
|
|
return true;
|
|
}
|
|
function clearPick() { ui.hidePickOverlay(); }
|
|
|
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
|
|
kick();
|
|
|
|
return { id: spec.id, onClockTick, handlePick, clearPick };
|
|
}
|