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