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>
This commit is contained in:
jing 2026-07-13 12:25:51 +10:00
parent 7356d62381
commit 6c7b945848
6 changed files with 901 additions and 5 deletions

View File

@ -38,18 +38,21 @@ export const CONFIG = {
sampleStepSec: 60, // one SGP4 sample per minute across the window
cap: 120, // hard ceiling on tracked satellites
// Each source is one Celestrak query. `filter` = keep only names containing
// one of these tokens; `keepOnly` = keep only these exact names.
// one of these tokens; `keepOnly` = keep only these exact names; `max` =
// cap this source's contribution so one big constellation can't crowd out
// the rest. Rare/high-value sources are listed first so the global cap
// never starves them.
sources: [
{ q: 'GROUP=stations&FORMAT=tle', keepOnly: ['ISS (ZARYA)', 'CSS (TIANHE)'] },
{ q: 'NAME=COSMOS%202486&FORMAT=tle' }, // Russian Persona-class recon
{ q: 'NAME=COSMOS%202506&FORMAT=tle' },
{
q: 'GROUP=resource&FORMAT=tle',
filter: ['WORLDVIEW', 'LEGION', 'PLEIADES', 'CAPELLA', 'GAOFEN', 'SKYSAT',
'ICEYE', 'UMBRA', 'LANDSAT', 'SENTINEL-1', 'SENTINEL-2',
'GEOEYE', 'KOMPSAT', 'CARTOSAT', 'RESURS'],
},
{ q: 'GROUP=stations&FORMAT=tle', keepOnly: ['ISS (ZARYA)', 'CSS (TIANHE)'] },
{ q: 'NAME=GAOFEN&FORMAT=tle' },
{ q: 'NAME=COSMOS%202486&FORMAT=tle' },
{ q: 'NAME=COSMOS%202506&FORMAT=tle' },
{ q: 'NAME=GAOFEN&FORMAT=tle', max: 30 }, // large Chinese EO fleet — cap it
],
},

158
js/layers/aircraft.js Normal file
View File

@ -0,0 +1,158 @@
// Live aircraft (ADS-B via OpenSky) as a BillboardCollection primitive.
// Thousands of billboards rebuilt wholesale each poll; entities would crawl.
// SPEC §6.3.
export default function create(ctx) {
const { viewer, CONFIG, lib, ui, Cesium } = ctx;
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
const glyph = lib.aircraftGlyph(); // shared canvas, tinted per-billboard
let enabled = true; // HUD checkbox
let isLive = true; // clock at wall-clock now
let inFlight = false; // a fetch is currently running
let timer = null; // pending setTimeout handle
let delay = CONFIG.aircraft.pollMs; // current cadence (grows on 429)
ui.addLayer('aircraft', 'Aircraft (live ADS-B)', true, (on) => {
enabled = on;
bc.show = on && isLive;
if (on) kick();
});
// Poll only while enabled, live, and the tab is visible.
function canPoll() {
return enabled && isLive && !document.hidden;
}
function schedule(ms) {
if (timer) clearTimeout(timer);
timer = setTimeout(tick, ms);
}
function kick() {
if (!canPoll() || timer || inFlight) return;
schedule(0);
}
async function tick() {
timer = null;
if (!canPoll() || inFlight) return;
inFlight = true;
try {
await poll();
} finally {
inFlight = false;
}
if (canPoll()) schedule(delay);
}
function buildUrl() {
let url = CONFIG.proxy.opensky; // RELATIVE — works at "/" and "/godsigh/"
const bb = CONFIG.aircraft.bbox;
if (bb) {
const q = new URLSearchParams({
lamin: bb.lamin, lomin: bb.lomin, lamax: bb.lamax, lomax: bb.lomax,
});
url = `${url}?${q.toString()}`;
}
return url;
}
async function poll() {
let res;
try {
res = await fetch(buildUrl());
} catch (err) {
ui.setStatus('aircraft', 'network error — retrying', 'warn');
return;
}
if (res.status === 429) {
delay = delay * 4; // back off
ui.setStatus('aircraft', 'rate-limited — anonymous OpenSky quota', 'warn');
return;
}
if (!res.ok) {
ui.setStatus('aircraft', `HTTP ${res.status} — retrying`, 'warn');
return;
}
const remaining = res.headers.get('X-Rate-Limit-Remaining');
let data;
try {
data = await res.json();
} catch (err) {
ui.setStatus('aircraft', 'bad response — retrying', 'warn');
return;
}
const states = Array.isArray(data && data.states) ? data.states : [];
bc.removeAll();
let count = 0;
for (const s of states) {
if (!s) continue;
const lon = s[5];
const lat = s[6];
const onGround = s[8];
if (lon == null || lat == null || onGround) continue;
const alt = s[7];
const track = s[10];
bc.add({
position: Cesium.Cartesian3.fromDegrees(lon, lat, alt || 0),
image: glyph,
color: lib.cz(lib.altitudeColor(CONFIG, alt)),
rotation: lib.headingToRotation(track), // lib negates — do NOT negate again
scale: 0.55,
id: {
layer: 'aircraft',
icao24: s[0],
callsign: (s[1] || '').trim(),
country: s[2],
alt,
vel: s[9],
track,
},
});
count++;
}
delay = CONFIG.aircraft.pollMs; // healthy response — reset cadence
const stamp = data.time ? new Date(data.time * 1000).toLocaleTimeString() : '—';
const quota = remaining != null ? ` · quota ${remaining}` : '';
ui.setStatus('aircraft', `${count} aircraft · ${stamp}${quota}`, 'ok');
}
function onClockTick(currentTime, live) {
isLive = live;
if (!isLive) {
bc.show = false; // pause: hide + skip fetches
return;
}
bc.show = enabled;
if (enabled) kick();
}
function handlePick(picked) {
if (picked?.id?.layer !== 'aircraft') return false;
const id = picked.id;
ui.showPickOverlay('✈ ' + (id.callsign || id.icao24), [
['Country', id.country || '—'],
['Altitude', `${Math.round(id.alt || 0)} m / ${Math.round((id.alt || 0) * 3.28084)} ft`],
['Speed', `${Math.round((id.vel || 0) * 1.94384)} kt`],
['Heading', `${Math.round(id.track || 0)}°`],
]);
return true;
}
function clearPick() {
ui.hidePickOverlay();
}
// One immediate fetch on init.
kick();
return { id: 'aircraft', onClockTick, handlePick, clearPick };
}

87
js/layers/events.js Normal file
View File

@ -0,0 +1,87 @@
// SPEC §6.5 — DEMO time-anchored event markers.
// Each marker's availability starts at its event time (now + offset) and runs to
// ctx.stop, so it APPEARS as the timeline slider crosses its moment. Every marker
// is an ILLUSTRATIVE PLACEHOLDER for a future real event feed — NOT a real record.
export default function create(ctx) {
const { viewer, CONFIG, lib, ui, Cesium, now, stop } = ctx;
const eventsDS = new Cesium.CustomDataSource('events');
viewer.dataSources.add(eventsDS);
const DISCLAIMER =
'ILLUSTRATIVE PLACEHOLDER — this is a DEMO marker, not a real strike/event ' +
'record. It stands in for a future real event feed.';
// offsetHours = when the marker appears (relative to wall-clock now).
const markers = [
{
name: 'Command installation strike [DEMO]',
lon: 51.4, lat: 32.6, offsetHours: -3,
blurb: 'Simulated ground strike on a command installation (inland Iran, approx coords).',
},
{
name: 'Recon satellite pass correlation [DEMO]',
lon: 51.4, lat: 32.6, offsetHours: -2,
blurb: 'Simulated correlation of a recon satellite pass with the site above (approx coords).',
},
{
name: 'Tanker incident [DEMO]',
lon: 56.5, lat: 26.5, offsetHours: 1,
blurb: 'Simulated tanker incident in the Strait of Hormuz (approx coords).',
},
{
name: 'Air-defense activation [DEMO]',
lon: 54.4, lat: 27.2, offsetHours: 3,
blurb: 'Simulated air-defense radar activation near the northern Gulf coast (approx coords).',
},
];
const color = lib.cz(CONFIG.colors.event);
let added = 0;
for (const m of markers) {
const eventTime = Cesium.JulianDate.addHours(now, m.offsetHours, new Cesium.JulianDate());
const when = m.offsetHours < 0
? `now ${m.offsetHours}h`
: `now +${m.offsetHours}h`;
eventsDS.entities.add({
name: m.name,
position: Cesium.Cartesian3.fromDegrees(m.lon, m.lat),
availability: new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start: eventTime, stop }),
]),
point: {
// Browser-time pulse: correct to run at render time (per SPEC note).
pixelSize: new Cesium.CallbackProperty(() => 8 + 4 * Math.sin(Date.now() / 300), false),
color,
outlineColor: Cesium.Color.WHITE,
outlineWidth: 1.5,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
text: m.name,
font: '12px sans-serif',
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(0, -14),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
description:
`<p><strong>${m.name}</strong></p>` +
`<p>${m.blurb}</p>` +
`<p>Appears at: ${when} (${m.lat.toFixed(2)}, ${m.lon.toFixed(2)}).</p>` +
`<p style="color:#ff9f40"><strong>${DISCLAIMER}</strong></p>`,
});
added += 1;
}
ui.addLayer('events', 'Events [DEMO]', true, (on) => { eventsDS.show = on; });
ui.setStatus('events', `${added} markers [DEMO]`, 'ok');
return { id: 'events' };
}

161
js/layers/infra.js Normal file
View File

@ -0,0 +1,161 @@
// Infrastructure overlay (SPEC §6.5): static vector context for the theater —
// maritime choke points, major oil pipelines, and key facilities. No time
// dynamics. All coordinates are approximate and every description says so.
export default function create(ctx) {
const { viewer, CONFIG, lib, ui, Cesium } = ctx;
const C = CONFIG.colors;
const infraDS = new Cesium.CustomDataSource('infra');
viewer.dataSources.add(infraDS);
const ents = infraDS.entities;
const APPROX = 'Location approximate — illustrative reference geometry, not survey-grade.';
// Shared label defaults: readable at range, fades in as you zoom.
const labelBase = {
font: '12px "Segoe UI", system-ui, sans-serif',
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.fromCssColorString('#0a0f14'),
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
translucencyByDistance: new Cesium.NearFarScalar(2.0e6, 1.0, 1.2e7, 0.0),
};
// ---- choke points: red ring + label ----
const chokepoints = [
{ name: 'Strait of Hormuz', lon: 56.5, lat: 26.6 },
{ name: 'Bab el-Mandeb', lon: 43.33, lat: 12.58 },
{ name: 'Suez Canal', lon: 32.35, lat: 30.45 },
{ name: 'Strait of Malacca', lon: 101.0, lat: 2.5 },
];
for (const cp of chokepoints) {
ents.add({
name: `Choke point — ${cp.name}`,
position: Cesium.Cartesian3.fromDegrees(cp.lon, cp.lat),
ellipse: {
semiMajorAxis: 80000,
semiMinorAxis: 80000,
material: Cesium.Color.fromCssColorString('rgba(255,0,60,1)').withAlpha(0.06),
outline: true,
outlineColor: lib.cz(C.chokepoint),
height: 0,
},
label: {
...labelBase,
text: cp.name,
fillColor: lib.cz(C.chokepoint),
pixelOffset: new Cesium.Cartesian2(0, -6),
},
description:
`<p><b>Maritime choke point.</b></p>` +
`<p>${cp.name} — a strategic strait for global energy and trade flows.</p>` +
`<p><i>${APPROX} The ring is a fixed ~80 km radius marker, not the true channel.</i></p>`,
});
}
// ---- pipelines: amber dashed polylines clamped to ground + midpoint label ----
const pipelines = [
{
name: 'EastWest (Petroline) crude pipeline',
note: 'Abqaiq → Yanbu',
coords: [49.68, 25.94, 46.7, 24.6, 44.5, 24.3, 41.5, 24.2, 38.06, 24.09],
},
{
name: 'HabshanFujairah crude pipeline',
note: 'bypasses the Strait of Hormuz',
coords: [53.61, 23.75, 54.6, 24.2, 55.5, 24.8, 56.33, 25.12],
},
];
for (const pl of pipelines) {
ents.add({
name: `Pipeline — ${pl.name}`,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(pl.coords),
width: 2,
clampToGround: true,
material: new Cesium.PolylineDashMaterialProperty({ color: lib.cz(C.pipeline) }),
},
description:
`<p><b>Oil pipeline.</b> ${pl.note}.</p>` +
`<p>${pl.name}.</p>` +
`<p><i>${APPROX} The route is a simplified schematic between endpoints.</i></p>`,
});
// Midpoint label at the middle vertex of the schematic route.
const mid = pipelineMidpoint(pl.coords);
ents.add({
name: `${pl.name} (label)`,
position: Cesium.Cartesian3.fromDegrees(mid[0], mid[1]),
label: { ...labelBase, text: pl.name, fillColor: lib.cz(C.pipeline) },
});
}
// ---- facilities: small square markers + type-annotated labels ----
const facilities = [
{ name: 'Ras Tanura', type: 'oil terminal', lon: 50.16, lat: 26.64 },
{ name: 'Abqaiq', type: 'oil processing plant', lon: 49.68, lat: 25.94 },
{ name: 'Yanbu', type: 'port / oil terminal', lon: 38.06, lat: 24.09 },
{ name: 'Kharg Island', type: 'oil export terminal', lon: 50.32, lat: 29.23 },
{ name: 'Fujairah', type: 'oil terminal', lon: 56.37, lat: 25.18 },
{ name: 'Jebel Ali', type: 'desalination plant', lon: 55.02, lat: 25.01 },
{ name: 'Ras Al-Khair', type: 'desalination plant', lon: 49.19, lat: 27.55 },
{ name: 'Al Udeid', type: 'air base', lon: 51.32, lat: 25.12 },
{ name: 'Haifa', type: 'refinery', lon: 35.05, lat: 32.79 },
];
const squareGlyph = facilitySquare(Cesium, C.facility);
for (const f of facilities) {
ents.add({
name: `Facility — ${f.name} ${f.type}`,
position: Cesium.Cartesian3.fromDegrees(f.lon, f.lat),
billboard: {
image: squareGlyph,
width: 9,
height: 9,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
...labelBase,
text: f.name,
fillColor: lib.cz(C.facility),
pixelOffset: new Cesium.Cartesian2(0, -8),
},
description:
`<p><b>${f.name}</b> — ${f.type}.</p>` +
`<p><i>${APPROX}</i></p>`,
});
}
// ---- HUD row + status ----
ui.addLayer('infra', 'Infrastructure', true, (on) => { infraDS.show = on; });
ui.setStatus(
'infra',
`${chokepoints.length} choke points · ${pipelines.length} pipelines · ${facilities.length} facilities`,
'ok',
);
return { id: 'infra' };
}
// Geographic midpoint = the middle vertex of the route's [lon,lat,...] array.
function pipelineMidpoint(coords) {
const n = coords.length / 2;
const i = Math.floor(n / 2) * 2;
return [coords[i], coords[i + 1]];
}
// Tiny filled square canvas for facility markers (billboard image).
function facilitySquare(Cesium, hex) {
const s = 16;
const c = document.createElement('canvas');
c.width = s;
c.height = s;
const g = c.getContext('2d');
g.fillStyle = hex;
g.strokeStyle = '#0a0f14';
g.lineWidth = 2;
g.fillRect(2, 2, s - 4, s - 4);
g.strokeRect(2, 2, s - 4, s - 4);
return c;
}

231
js/layers/satellites.js Normal file
View File

@ -0,0 +1,231 @@
// Satellites layer (SPEC §6.2): SGP4-propagated EO/recon satellites as
// time-dynamic Cesium entities with orbit paths. Fetches TLEs from Celestrak
// via the same-origin proxy; one failed source never sinks the layer.
export default function create(ctx) {
const { viewer, CONFIG, lib, ui, Cesium, start, stop } = ctx;
const satellite = window.satellite;
const ds = new Cesium.CustomDataSource('satellites');
viewer.dataSources.add(ds);
// Ordered token → color lookup (first match wins).
const COLOR_TOKENS = ['GAOFEN', 'COSMOS', 'WORLDVIEW', 'LEGION', 'CAPELLA',
'PLEIADES', 'SENTINEL', 'ISS', 'CSS'];
// Live toggle state — also applied to entities created later during the
// chunked propagation loop, so toggling off mid-load stays honored.
let showSats = true;
let showPaths = true;
ui.addLayer('satellites', 'Satellites', true, (on) => {
showSats = on;
for (const e of ds.entities.values) {
if (e.point) e.point.show = on;
if (e.label) e.label.show = on;
}
});
ui.addLayer('sat-paths', 'Orbit paths', true, (on) => {
showPaths = on;
for (const e of ds.entities.values) {
if (e.path) e.path.show = on;
}
});
ui.setStatus('satellites', 'loading TLEs…', 'warn');
ui.setStatus('sat-paths', '', 'ok');
// --- Parse raw TLE text into {name, l1, l2} triplets. ---
function parseTLE(text) {
const lines = text.split(/\r?\n/).map((s) => s.replace(/\s+$/, '')).filter((s) => s.length);
// Celestrak "NAME=" queries can return "No GP data found" as plain text:
// a valid TLE stream's first data line (line1) starts with '1'.
const out = [];
for (let i = 0; i + 2 < lines.length; i += 3) {
const name = lines[i];
const l1 = lines[i + 1];
const l2 = lines[i + 2];
if (!l1 || !l2 || l1[0] !== '1' || l2[0] !== '2') return out.length ? out : [];
out.push({ name: name.trim(), l1, l2 });
}
return out;
}
function passesSource(name, source) {
if (source.keepOnly) return source.keepOnly.includes(name);
if (source.filter) {
const up = name.toUpperCase();
return source.filter.some((tok) => up.includes(tok.toUpperCase()));
}
return true;
}
function colorForName(name) {
const up = name.toUpperCase();
for (const tok of COLOR_TOKENS) {
if (up.includes(tok)) return CONFIG.colors[tok] || CONFIG.colors.satDefault;
}
return CONFIG.colors.satDefault;
}
const yieldTick = () => new Promise((r) => setTimeout(r, 0));
async function run() {
const sources = CONFIG.satellites.sources;
const settled = await Promise.allSettled(
sources.map((s) => fetch(`${CONFIG.proxy.celestrak}?${s.q}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}))
);
// Collect candidate sats, deduped by NORAD id, tagged with their source.
const seen = new Set();
const candidates = [];
let failedSources = 0;
settled.forEach((r, i) => {
const source = sources[i];
if (r.status === 'rejected') { failedSources++; return; }
const triplets = parseTLE(r.value);
let srcCount = 0;
for (const t of triplets) {
if (candidates.length >= CONFIG.satellites.cap) break;
if (source.max && srcCount >= source.max) break;
if (!passesSource(t.name, source)) continue;
let satrec;
try { satrec = satellite.twoline2satrec(t.l1, t.l2); } catch { continue; }
if (!satrec || satrec.error) continue;
const id = satrec.satnum;
if (seen.has(id)) continue;
seen.add(id);
candidates.push({ name: t.name, satrec, q: source.q });
srcCount++;
}
});
if (!candidates.length) {
const msg = failedSources ? `no data (${failedSources}/${sources.length} sources failed)` : 'no satellites';
ui.setStatus('satellites', msg, 'err');
return;
}
// --- Chunked SGP4 propagation so first paint isn't blocked. ---
const stepSec = CONFIG.satellites.sampleStepSec;
let built = 0;
const perSource = new Map(); // q → count
for (let i = 0; i < candidates.length; i += 10) {
const chunk = candidates.slice(i, i + 10);
for (const c of chunk) {
const entity = buildEntity(c, stepSec);
if (entity) {
ds.entities.add(entity);
built++;
perSource.set(c.q, (perSource.get(c.q) || 0) + 1);
}
}
ui.setStatus('satellites', `propagating ${Math.min(i + 10, candidates.length)}/${candidates.length}`, 'warn');
await yieldTick();
}
if (!built) {
ui.setStatus('satellites', 'all TLEs failed propagation', 'err');
return;
}
const parts = [...perSource.entries()].map(([q, n]) => `${n}×${q.split('&')[0]}`);
const summary = `${built} sats` + (failedSources ? ` (${failedSources} src failed)` : '');
ui.setStatus('satellites', summary, failedSources ? 'warn' : 'ok');
ui.setStatus('sat-paths', parts.join(', '), 'ok');
}
function buildEntity(c, stepSec) {
const { name, satrec, q } = c;
const posProp = new Cesium.SampledPositionProperty();
let valid = 0;
let t = start.clone();
while (Cesium.JulianDate.lessThanOrEquals(t, stop)) {
const date = Cesium.JulianDate.toDate(t);
let pv;
try { pv = satellite.propagate(satrec, date); } catch { pv = null; }
if (pv && pv.position) {
const p = pv.position;
if (isFinite(p.x) && isFinite(p.y) && isFinite(p.z)) {
const gmst = satellite.gstime(date);
const geo = satellite.eciToGeodetic(p, gmst);
if (isFinite(geo.longitude) && isFinite(geo.latitude) && isFinite(geo.height)) {
posProp.addSample(t.clone(), Cesium.Cartesian3.fromRadians(
geo.longitude, geo.latitude, geo.height * 1000));
valid++;
}
}
}
t = Cesium.JulianDate.addSeconds(t, stepSec, new Cesium.JulianDate());
}
if (valid < 50) return null;
posProp.setInterpolationOptions({
interpolationDegree: 5,
interpolationAlgorithm: Cesium.LagrangePolynomialApproximation,
});
const hex = colorForName(name);
const color = lib.cz(hex);
const periodSec = (2 * Math.PI / satrec.no) * 60; // satrec.no is rad/min
// Orbital element derivations for the InfoBox.
const incDeg = Cesium.Math.toDegrees(satrec.inclo);
const nRadS = satrec.no / 60; // rad/s
const a = Math.pow(398600.4418 / (nRadS * nRadS), 1 / 3); // km
const e = satrec.ecco;
const apogee = a * (1 + e) - 6378.137;
const perigee = a * (1 - e) - 6378.137;
const description =
`<table class="cesium-infoBox-defaultTable"><tbody>` +
`<tr><th>NORAD</th><td>${satrec.satnum}</td></tr>` +
`<tr><th>Source</th><td>${q}</td></tr>` +
`<tr><th>Period</th><td>${(periodSec / 60).toFixed(1)} min</td></tr>` +
`<tr><th>Inclination</th><td>${incDeg.toFixed(2)}°</td></tr>` +
`<tr><th>Apogee</th><td>${apogee.toFixed(0)} km</td></tr>` +
`<tr><th>Perigee</th><td>${perigee.toFixed(0)} km</td></tr>` +
`</tbody></table>`;
return new Cesium.Entity({
name,
position: posProp,
point: {
pixelSize: 6,
color,
show: showSats,
},
label: {
text: name,
show: showSats,
font: '10px ui-monospace',
pixelOffset: new Cesium.Cartesian2(0, -12),
translucencyByDistance: new Cesium.NearFarScalar(1.5e6, 1.0, 1.5e7, 0.0),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
path: {
width: 1,
show: showPaths,
resolution: 120,
leadTime: periodSec / 2,
trailTime: periodSec / 2,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.2,
color: color.withAlpha(0.35),
}),
},
description,
});
}
run().catch((err) => {
console.error('[godsigh] satellites layer failed:', err);
ui.setStatus('satellites', `error: ${err.message || err}`, 'err');
});
return { id: 'satellites' };
}

256
js/layers/ships.js Normal file
View File

@ -0,0 +1,256 @@
// 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' };
}