ais_bridge.py: a small daemon holding the aisstream.io websocket (free key, server-side only), tracking every vessel heard (15-min freshness window) and atomically writing ais_snapshot.json into the web root every 20 s. The js/layers/ais.js layer polls that static file — no request-path server code, identical under dev serve.py and prod nginx; rolling query bucket defeats CDN caching. Verified locally: 15,844 live vessels within minutes of connect — the Dover Strait separation lanes are visibly full of real traffic; click any ship for name/MMSI/type/speed/course/status/destination (colored by type: cargo green, tanker amber, passenger blue, fishing purple, anchored grey). Live-only (hidden when scrubbed). Demo ships demoted to default-off; the dormant browser-side AIS scaffold in ships.js stays inert (key would be browser-exposed — the bridge supersedes it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.6 KiB
JavaScript
126 lines
4.6 KiB
JavaScript
// Live AIS shipping (Wave 7) — every real vessel aisstream.io can hear,
|
|
// worldwide. ais_bridge.py (a tiny daemon next to the web root) holds the
|
|
// websocket and drops ais_snapshot.json every ~20 s; this layer just polls
|
|
// that static file. Live-only: hidden when the clock is scrubbed off now
|
|
// (there's no ship history recorder).
|
|
|
|
const POLL_MS = 30000;
|
|
const NAV = {
|
|
0: 'under way', 1: 'at anchor', 2: 'not under command', 3: 'restricted manoeuvre',
|
|
4: 'constrained by draught', 5: 'moored', 6: 'aground', 7: 'fishing', 8: 'under sail',
|
|
};
|
|
function typeLabel(t) {
|
|
if (t == null) return null;
|
|
if (t === 30) return 'fishing';
|
|
if (t === 35) return 'military';
|
|
if (t === 36 || t === 37) return 'sailing / pleasure';
|
|
if (t === 52) return 'tug';
|
|
if (t >= 60 && t <= 69) return 'passenger';
|
|
if (t >= 70 && t <= 79) return 'cargo';
|
|
if (t >= 80 && t <= 89) return 'tanker';
|
|
return `type ${t}`;
|
|
}
|
|
|
|
export default function create(ctx) {
|
|
const { viewer, Cesium, ui } = ctx;
|
|
const pts = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection());
|
|
pts.show = false;
|
|
|
|
const C_CARGO = Cesium.Color.fromCssColorString('#39d98a');
|
|
const C_TANKER = Cesium.Color.fromCssColorString('#ff9f43');
|
|
const C_PASS = Cesium.Color.fromCssColorString('#54a0ff');
|
|
const C_FISH = Cesium.Color.fromCssColorString('#c58fff');
|
|
const C_OTHER = Cesium.Color.fromCssColorString('#9fd8cb');
|
|
const C_STILL = Cesium.Color.fromCssColorString('#7c8b97');
|
|
const OUTLINE = Cesium.Color.BLACK;
|
|
const colorFor = (s) => {
|
|
if ((s.sog ?? 0) < 0.5 || s.nav === 1 || s.nav === 5) return C_STILL;
|
|
const t = s.type;
|
|
if (t >= 70 && t <= 79) return C_CARGO;
|
|
if (t >= 80 && t <= 89) return C_TANKER;
|
|
if (t >= 60 && t <= 69) return C_PASS;
|
|
if (t === 30) return C_FISH;
|
|
return C_OTHER;
|
|
};
|
|
|
|
let enabled = false;
|
|
let live = true;
|
|
let timer = null;
|
|
let lastCount = 0;
|
|
let lastTs = null;
|
|
|
|
ui.addLayer('ais', 'Ships (live AIS)', true, (on) => {
|
|
enabled = on;
|
|
pts.show = on && live;
|
|
if (on && !timer) { poll(); timer = setInterval(poll, POLL_MS); }
|
|
if (!on && timer) { clearInterval(timer); timer = null; }
|
|
if (!on) { ui.hidePickOverlay(); ui.setStatus('ais', '', 'ok'); }
|
|
else status();
|
|
}, 'Sea');
|
|
enabled = true;
|
|
pts.show = true; // defaultOn — addLayer doesn't fire onToggle for the initial state
|
|
poll();
|
|
timer = setInterval(poll, POLL_MS);
|
|
|
|
function status() {
|
|
if (!enabled) return;
|
|
if (!live) { ui.setStatus('ais', 'live only — return clock to now', 'warn'); return; }
|
|
const age = lastTs ? Math.round(Date.now() / 1000 - lastTs) : null;
|
|
ui.setStatus('ais', lastCount ? `${lastCount} live vessels · ${age}s ago · aisstream.io` : 'waiting for bridge…', lastCount ? 'ok' : 'warn');
|
|
}
|
|
|
|
async function poll() {
|
|
if (!enabled || document.hidden) return;
|
|
let res;
|
|
try {
|
|
// rolling bucket busts any CDN cache; no-store busts the browser's
|
|
res = await fetch(`ais_snapshot.json?t=${Math.floor(Date.now() / POLL_MS)}`, { cache: 'no-store' });
|
|
} catch { ui.setStatus('ais', 'network error', 'warn'); return; }
|
|
if (res.status === 404) { ui.setStatus('ais', 'bridge offline (no snapshot)', 'err'); return; }
|
|
if (!res.ok) { ui.setStatus('ais', `HTTP ${res.status}`, 'warn'); return; }
|
|
let snap;
|
|
try { snap = await res.json(); } catch { ui.setStatus('ais', 'bad snapshot', 'err'); return; }
|
|
const ships = snap.ships || [];
|
|
pts.removeAll();
|
|
for (const s of ships) {
|
|
if (!isFinite(s.lat) || !isFinite(s.lon)) continue;
|
|
pts.add({
|
|
position: Cesium.Cartesian3.fromDegrees(s.lon, s.lat),
|
|
pixelSize: (s.sog ?? 0) > 0.5 ? 4 : 3,
|
|
color: colorFor(s),
|
|
outlineColor: OUTLINE,
|
|
outlineWidth: 1,
|
|
disableDepthTestDistance: 50000,
|
|
id: { layer: 'ais', ...s },
|
|
});
|
|
}
|
|
lastCount = ships.length;
|
|
lastTs = snap.ts;
|
|
status();
|
|
}
|
|
|
|
function onClockTick(_t, isLive) {
|
|
if (live === isLive) return;
|
|
live = isLive;
|
|
pts.show = enabled && live;
|
|
status();
|
|
}
|
|
|
|
function handlePick(picked) {
|
|
if (!enabled || picked?.id?.layer !== 'ais') return false;
|
|
const d = picked.id;
|
|
ui.showPickOverlay('🚢 ' + (d.name || `MMSI ${d.mmsi}`), [
|
|
['MMSI', String(d.mmsi)],
|
|
['Type', typeLabel(d.type) || '—'],
|
|
['Speed', d.sog != null ? `${d.sog.toFixed(1)} kn` : '—'],
|
|
['Course', d.cog != null ? `${Math.round(d.cog)}°` : '—'],
|
|
['Status', NAV[d.nav] || '—'],
|
|
['Destination', d.dest || '—'],
|
|
]);
|
|
return true;
|
|
}
|
|
function clearPick() { ui.hidePickOverlay(); }
|
|
|
|
return { id: 'ais', handlePick, clearPick, onClockTick };
|
|
}
|