GODSIGH/js/layers/ships.js
jing 6c7b945848 phases 2-5: satellite, aircraft, ship, infra & event layers
- satellites: Celestrak TLE -> SGP4 -> time-dynamic entities w/ glowing orbit
  paths; per-source cap + reordering so COSMOS/ISS aren't starved by the 72-sat
  Gaofen fleet (now ~97 sats, diverse); chunked propagation, orbital InfoBox
- aircraft: global OpenSky ADS-B -> BillboardCollection primitive (6444 planes
  verified), altitude-banded, quota/scrub/visibility-gated polling, click overlay
- ships: [DEMO] fleet through Hormuz incl. toll-route carrier, dark-vessel AIS
  gap, Fujairah idle cluster; optional aisstream.io live path
- infra: choke-point rings, Petroline + Habshan-Fujairah pipelines, 9 facilities
- events: [DEMO] time-anchored pulsing markers that appear as the slider crosses
- authored + adversarially hardened via parallel agent workflow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:25:51 +10:00

257 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
...extra,
});
function describe(flag, type, speed, note) {
return (
`<table class="cesium-infoBox-defaultTable"><tbody>` +
`<tr><th>Flag</th><td>${flag}</td></tr>` +
`<tr><th>Type</th><td>${type}</td></tr>` +
`<tr><th>Speed</th><td>${speed}</td></tr>` +
`<tr><th>Route</th><td>${note}</td></tr>` +
`</tbody></table><p style="opacity:.7">Illustrative demo vessel — not live AIS.</p>`
);
}
// 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),
});
}
let vessels = 0;
try {
// 1) Tankers/LNG through the Hormuz deep channel — eastbound + westbound lanes.
const eb1 = [[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 eb2 = [[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]];
// Westbound lanes: reverse of the eastbound tracks, nudged north to separate lanes.
const shiftLat = (wp, d) => wp.map(([lon, lat, h]) => [lon, lat + d, h]);
const wb1 = shiftLat(reverse(eb1), 0.18);
const wb2 = shiftLat(reverse(eb2), 0.18);
addVessel('GULF PIONEER [DEMO]', eb1, C.shipNormal,
{ flag: 'Marshall Islands', type: 'Crude oil tanker (VLCC)', speed: '12.4 kn', note: 'Eastbound, Hormuz deep channel' });
addVessel('AL RUWAIS [DEMO]', eb2, C.shipNormal,
{ flag: 'Qatar', type: 'LNG carrier (Q-Max)', speed: '15.1 kn', note: 'Eastbound, Hormuz deep channel' });
addVessel('STAR OF MUSCAT [DEMO]', wb1, C.shipNormal,
{ flag: 'Panama', type: 'Product tanker (LR2)', speed: '11.8 kn', note: 'Westbound, Hormuz deep channel' });
addVessel('NORDIC AURORA [DEMO]', wb2, C.shipNormal,
{ flag: 'Liberia', type: 'LNG carrier', speed: '14.6 kn', note: 'Westbound, Hormuz deep channel' });
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 now2h 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 now2h → 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,
},
label: labelGraphic({ text: `IDLE ${i + 1} [DEMO]`, scale: 0.75 }),
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');
}
// ---- HUD toggle ----
// Controls whichever fleet is actually on screen: the demo fleet, or the live
// AIS fleet once it takes over. Must not resurrect the hidden demo fleet on top
// of live vessels, so it follows liveActive rather than always poking shipsDS.
let aisDS = null;
let liveActive = false;
ui.addLayer('ships', 'Ships [DEMO]', true, (on) => {
if (liveActive) { if (aisDS) aisDS.show = on; }
else { shipsDS.show = on; }
});
// ---- 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);
} else {
if (byMMSI.size >= CAP) {
const oldest = byMMSI.keys().next().value; // Map preserves insertion order
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: `<p>Live AIS — MMSI ${mmsi}</p>`,
});
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' };
}