Every marker used disableDepthTestDistance: Infinity, so points/billboards on the FAR side of the Earth drew on top of the near hemisphere instead of being occluded — reading as 'dots stuck, not moving with the globe' when you rotate. Changed to 50000 (50km) across all layers: the globe now occludes far-side markers, while you can still zoom right up to one without it clipping into the surface. Also made the billboard layers (aircraft/military/adsb/radius) explicit for consistent behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
11 KiB
JavaScript
270 lines
11 KiB
JavaScript
// 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: 50000,
|
||
...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: 50000,
|
||
},
|
||
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: 50000,
|
||
},
|
||
// 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: 50000,
|
||
},
|
||
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' };
|
||
}
|