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:
parent
7356d62381
commit
6c7b945848
13
js/config.js
13
js/config.js
@ -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
158
js/layers/aircraft.js
Normal 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
87
js/layers/events.js
Normal 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
161
js/layers/infra.js
Normal 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: 'East–West (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: 'Habshan–Fujairah 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
231
js/layers/satellites.js
Normal 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
256
js/layers/ships.js
Normal 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 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,
|
||||
},
|
||||
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' };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user