GODSIGH/js/layers/ships.js
jing 7da9eeaeb0 phase 7-8: adversarial-review fixes + README
Applied 4 confirmed findings from the multi-agent review (5th was a documented
deliberate deviation):
- aircraft: self-healing visibilitychange listener — the live feed no longer
  stalls permanently after tab hide->show while the clock is paused (MAJOR)
- SPEC §9: nginx proxy_pass with $is_args$args needs a resolver or prod 502s
  while nginx -t still passes — documented + hardened the deploy step (MAJOR)
- ships: live-AIS map now evicts true least-recently-updated (move-to-end),
  not oldest-inserted
- ships: corrected inverted Hormuz lane direction labels (tracks with
  decreasing longitude sail west/inbound, not east)
- README: full data-source + attribution table, real-vs-DEMO breakdown,
  architecture, roadmap

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

270 lines
11 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,
// 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 (
`<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),
});
}
// ---- 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 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,
},
// 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: `<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' };
}