// Ships layer (SPEC §6.4). Demo fleet of time-dynamic vessels crossing the // clock window, plus an optional guarded live-AIS path. Every demo vessel name // ends in " [DEMO]". Ships are Cesium entities (free InfoBox + time dynamics). export default function create(ctx) { const { viewer, CONFIG, lib, ui, Cesium, start, stop, now } = ctx; const C = CONFIG.colors; const glyph = lib.shipGlyph(); const shipsDS = new Cesium.CustomDataSource('ships'); viewer.dataSources.add(shipsDS); // ---- helpers ---- const at = (h) => Cesium.JulianDate.addHours(now, h, new Cesium.JulianDate()); // Build a SampledPositionProperty from [lon, lat, hourOffset] waypoints. function track(waypoints) { const p = new Cesium.SampledPositionProperty(); for (const [lon, lat, h] of waypoints) { p.addSample(at(h), Cesium.Cartesian3.fromDegrees(lon, lat, 0)); } return p; } // Reverse a track's geographic direction while keeping time increasing. const reverse = (wp) => wp.slice().reverse().map(([lon, lat, h]) => [lon, lat, -h]); // Fixed billboard rotation from the great-circle bearing of first→last waypoint. function rotationOf(wp) { const a = wp[0], b = wp[wp.length - 1]; return lib.headingToRotation(lib.bearingDeg(a[1], a[0], b[1], b[0])); } const labelGraphic = (extra = {}) => ({ font: '11px sans-serif', fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -14), scale: 0.9, // Fade labels out at the global overview so the dense strait declutters; // they sharpen as you zoom into the theater. translucencyByDistance: new Cesium.NearFarScalar(1.5e6, 1.0, 6.0e6, 0.0), disableDepthTestDistance: Number.POSITIVE_INFINITY, ...extra, }); function describe(flag, type, speed, note) { return ( `` + `` + `` + `` + `` + `
Flag${flag}
Type${type}
Speed${speed}
Route${note}

Illustrative demo vessel — not live AIS.

` ); } // Add a moving vessel entity from a waypoint track. function addVessel(name, wp, color, meta, availability) { return shipsDS.entities.add({ name, position: track(wp), availability, billboard: { image: glyph, color: lib.cz(color), rotation: rotationOf(wp), scale: 0.95, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, label: labelGraphic({ text: name }), description: describe(meta.flag, meta.type, meta.speed, meta.note), }); } // ---- HUD toggle ---- // Registered BEFORE the fleet is built so the later ui.setStatus('ships', …) // lands on an existing row (setStatus no-ops if the row isn't registered yet). // The callback controls whichever fleet is on screen: the demo fleet, or the // live-AIS fleet once it takes over (tracked by liveActive) — so it never // resurrects the hidden demo fleet on top of live vessels. let aisDS = null; let liveActive = false; ui.addLayer('ships', 'Ships [DEMO]', true, (on) => { if (liveActive) { if (aisDS) aisDS.show = on; } else { shipsDS.show = on; } }); let vessels = 0; try { // 1) Tankers/LNG through the Hormuz deep channel, two opposing lanes. // laneIn: waypoints run 56.9°E → 53.5°E as time advances, i.e. longitude // DECREASES → the vessel sails WEST, inbound to the Persian Gulf. const laneIn1 = [[56.9, 25.4, -6], [56.4, 26.2, -2.5], [55.6, 26.55, 0], [54.5, 26.2, 3], [53.5, 25.7, 6]]; const laneIn2 = [[56.95, 25.25, -5], [56.45, 26.05, -1.5], [55.65, 26.4, 0.5], [54.55, 26.05, 3.5], [53.55, 25.55, 6]]; // Opposing lanes: reversed tracks (longitude increases → sailing EAST, // outbound to the Gulf of Oman), nudged north to separate the two lanes. const shiftLat = (wp, d) => wp.map(([lon, lat, h]) => [lon, lat + d, h]); const laneOut1 = shiftLat(reverse(laneIn1), 0.18); const laneOut2 = shiftLat(reverse(laneIn2), 0.18); addVessel('GULF PIONEER [DEMO]', laneIn1, C.shipNormal, { flag: 'Marshall Islands', type: 'Crude oil tanker (VLCC)', speed: '12.4 kn', note: 'Westbound — inbound to the Persian Gulf' }); addVessel('AL RUWAIS [DEMO]', laneIn2, C.shipNormal, { flag: 'Qatar', type: 'LNG carrier (Q-Max)', speed: '15.1 kn', note: 'Westbound — inbound to the Persian Gulf' }); addVessel('STAR OF MUSCAT [DEMO]', laneOut1, C.shipNormal, { flag: 'Panama', type: 'Product tanker (LR2)', speed: '11.8 kn', note: 'Eastbound — outbound to the Gulf of Oman' }); addVessel('NORDIC AURORA [DEMO]', laneOut2, C.shipNormal, { flag: 'Liberia', type: 'LNG carrier', speed: '14.6 kn', note: 'Eastbound — outbound to the Gulf of Oman' }); vessels += 4; // 2) "Toll-route" LPG carrier hugging the northern (Iranian-waters) coastline. const toll = [[56.5, 26.55, -6], [56.0, 26.75, -3], [55.4, 26.95, 0], [54.9, 26.8, 3], [54.5, 26.6, 6]]; addVessel('PERSIS CARRIER [DEMO]', toll, C.shipToll, { flag: 'Iran', type: 'LPG carrier', speed: '10.2 kn', note: 'Hugging northern coast — illustrative "IRGC escort toll-booth" storyline' }); vessels += 1; // 3) Dark vessel — AIS goes silent between now−2h and now+1h. Availability is two // intervals; a separate dashed polyline visualizes the gap while scrubbing. const darkWp = [[56.2, 25.6, -6], [55.7, 26.35, -2], [54.9, 26.3, 1], [54.0, 25.9, 6]]; const darkAvail = new Cesium.TimeIntervalCollection([ new Cesium.TimeInterval({ start: start, stop: at(-2) }), new Cesium.TimeInterval({ start: at(1), stop: stop }), ]); addVessel('SHADOW MERIDIAN [DEMO]', darkWp, C.shipDark, { flag: 'Unregistered', type: 'Suspected sanctioned crude tanker', speed: '9.7 kn', note: 'AIS dark gap now−2h → now+1h (illustrative dark-vessel detection)' }, darkAvail); vessels += 1; // Static dashed track across the gap: last-seen (h=−2) → reacquired (h=+1). const lastSeen = darkWp[1]; // 55.7, 26.35 at h=−2 const reacq = darkWp[2]; // 54.9, 26.3 at h=+1 const midLon = (lastSeen[0] + reacq[0]) / 2; const midLat = (lastSeen[1] + reacq[1]) / 2; shipsDS.entities.add({ name: 'AIS DARK GAP [DEMO]', position: Cesium.Cartesian3.fromDegrees(midLon, midLat, 0), polyline: { positions: Cesium.Cartesian3.fromDegreesArray([lastSeen[0], lastSeen[1], reacq[0], reacq[1]]), width: 2, material: new Cesium.PolylineDashMaterialProperty({ color: lib.cz(C.shipDark), dashLength: 16 }), }, label: labelGraphic({ text: 'AIS DARK GAP [DEMO]', fillColor: lib.cz(C.shipDark), pixelOffset: new Cesium.Cartesian2(0, 0), verticalOrigin: Cesium.VerticalOrigin.MIDDLE, }), description: describe('—', 'Dark-vessel track gap', 'n/a', 'Straight-line inference between last-seen and reacquired AIS fixes.'), }); // 4) Idle tankers clustered at the Fujairah anchorage — the "floating parking lot". const idle = [ [56.52, 25.12], [56.58, 25.18], [56.63, 25.15], [56.55, 25.22], [56.61, 25.10], [56.67, 25.20], ]; idle.forEach(([lon, lat], i) => { shipsDS.entities.add({ name: `FUJAIRAH IDLE ${i + 1} [DEMO]`, position: Cesium.Cartesian3.fromDegrees(lon, lat, 0), billboard: { image: glyph, color: lib.cz(C.shipIdle), scale: 0.85, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, // No label — six identical anchored tankers would just stack. The // vessel is still identified via its InfoBox on click. description: describe('Various', 'Tanker at anchor', '0.0 kn', 'Idle at Fujairah anchorage ("floating parking lot").'), }); }); vessels += idle.length; ui.setStatus('ships', `${vessels} vessels [DEMO]`, 'ok'); } catch (err) { console.error('[godsigh] ships demo fleet failed:', err); ui.setStatus('ships', 'demo fleet error', 'err'); } // ---- Optional live AIS (guarded, default off) ---- // Only attempts a socket if a key is configured. On the first position report // the demo fleet is hidden in favor of live vessels. Never blocks init. if (CONFIG.AISSTREAM_API_KEY) { startLiveAIS(); } function startLiveAIS() { try { aisDS = new Cesium.CustomDataSource('ais-live'); viewer.dataSources.add(aisDS); const byMMSI = new Map(); // MMSI -> entity const CAP = 500; const ws = new WebSocket('wss://stream.aisstream.io/v0/stream'); ws.onopen = () => { ws.send(JSON.stringify({ APIKey: CONFIG.AISSTREAM_API_KEY, BoundingBoxes: [[[10, 30], [35, 70]]], FilterMessageTypes: ['PositionReport'], })); }; ws.onmessage = (evt) => { let msg; try { msg = JSON.parse(evt.data); } catch { return; } // Verify shape at runtime rather than trusting field names blindly. const meta = msg.MetaData || {}; const pr = msg.Message && msg.Message.PositionReport; if (!pr) return; const mmsi = meta.MMSI != null ? String(meta.MMSI) : String(pr.UserID); const lat = pr.Latitude, lon = pr.Longitude; if (mmsi == null || !isFinite(lat) || !isFinite(lon)) return; if (!liveActive) { liveActive = true; shipsDS.show = false; // hide demo fleet once real data flows // Leave the HUD checkbox checked: the ships layer is still visible // (now live vessels), and the toggle now controls that live fleet. } const pos = Cesium.Cartesian3.fromDegrees(lon, lat, 0); const name = (meta.ShipName || `MMSI ${mmsi}`).trim(); let ent = byMMSI.get(mmsi); if (ent) { ent.position = pos; if (isFinite(pr.Cog)) ent.billboard.rotation = lib.headingToRotation(pr.Cog); // Move to the end so keys().next() yields a true least-recently-updated // victim on eviction (Map preserves insertion order). byMMSI.delete(mmsi); byMMSI.set(mmsi, ent); } else { if (byMMSI.size >= CAP) { const oldest = byMMSI.keys().next().value; // least-recently-updated const e = byMMSI.get(oldest); if (e) aisDS.entities.remove(e); byMMSI.delete(oldest); } ent = aisDS.entities.add({ name, position: pos, billboard: { image: glyph, color: lib.cz(C.shipNormal), rotation: isFinite(pr.Cog) ? lib.headingToRotation(pr.Cog) : 0, scale: 0.8, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, label: labelGraphic({ text: name, scale: 0.7 }), description: `

Live AIS — MMSI ${mmsi}

`, }); byMMSI.set(mmsi, ent); } ui.setStatus('ships', `${byMMSI.size} live AIS`, 'ok'); }; ws.onerror = () => ui.setStatus('ships', 'live AIS error — demo fleet', 'warn'); ws.onclose = () => { if (liveActive) ui.setStatus('ships', 'live AIS closed', 'warn'); }; } catch (err) { console.error('[godsigh] live AIS init failed:', err); } } return { id: 'ships' }; }