GODSIGH/js/layers/ais.js
type-two e4af0fb01d feat: Wave 7 — REAL live global shipping via aisstream.io
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>
2026-07-30 11:29:00 +10:00

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 };
}