From 6c7b945848dac83745268022683043f209c69b0d Mon Sep 17 00:00:00 2001 From: jing Date: Mon, 13 Jul 2026 12:25:51 +1000 Subject: [PATCH] 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 --- js/config.js | 13 +- js/layers/aircraft.js | 158 +++++++++++++++++++++++++ js/layers/events.js | 87 ++++++++++++++ js/layers/infra.js | 161 +++++++++++++++++++++++++ js/layers/satellites.js | 231 ++++++++++++++++++++++++++++++++++++ js/layers/ships.js | 256 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 901 insertions(+), 5 deletions(-) create mode 100644 js/layers/aircraft.js create mode 100644 js/layers/events.js create mode 100644 js/layers/infra.js create mode 100644 js/layers/satellites.js create mode 100644 js/layers/ships.js diff --git a/js/config.js b/js/config.js index 8d8f7cd..4dfe173 100644 --- a/js/config.js +++ b/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 ], }, diff --git a/js/layers/aircraft.js b/js/layers/aircraft.js new file mode 100644 index 0000000..62aa426 --- /dev/null +++ b/js/layers/aircraft.js @@ -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 }; +} diff --git a/js/layers/events.js b/js/layers/events.js new file mode 100644 index 0000000..4e1c31c --- /dev/null +++ b/js/layers/events.js @@ -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: + `

${m.name}

` + + `

${m.blurb}

` + + `

Appears at: ${when} (${m.lat.toFixed(2)}, ${m.lon.toFixed(2)}).

` + + `

${DISCLAIMER}

`, + }); + added += 1; + } + + ui.addLayer('events', 'Events [DEMO]', true, (on) => { eventsDS.show = on; }); + ui.setStatus('events', `${added} markers [DEMO]`, 'ok'); + + return { id: 'events' }; +} diff --git a/js/layers/infra.js b/js/layers/infra.js new file mode 100644 index 0000000..ddaf713 --- /dev/null +++ b/js/layers/infra.js @@ -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: + `

Maritime choke point.

` + + `

${cp.name} — a strategic strait for global energy and trade flows.

` + + `

${APPROX} The ring is a fixed ~80 km radius marker, not the true channel.

`, + }); + } + + // ---- 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: + `

Oil pipeline. ${pl.note}.

` + + `

${pl.name}.

` + + `

${APPROX} The route is a simplified schematic between endpoints.

`, + }); + // 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: + `

${f.name} — ${f.type}.

` + + `

${APPROX}

`, + }); + } + + // ---- 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; +} diff --git a/js/layers/satellites.js b/js/layers/satellites.js new file mode 100644 index 0000000..5d65339 --- /dev/null +++ b/js/layers/satellites.js @@ -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 = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
NORAD${satrec.satnum}
Source${q}
Period${(periodSec / 60).toFixed(1)} min
Inclination${incDeg.toFixed(2)}°
Apogee${apogee.toFixed(0)} km
Perigee${perigee.toFixed(0)} km
`; + + 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' }; +} diff --git a/js/layers/ships.js b/js/layers/ships.js new file mode 100644 index 0000000..07edf29 --- /dev/null +++ b/js/layers/ships.js @@ -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 ( + `` + + `` + + `` + + `` + + `` + + `
Flag${flag}
Type${type}
Speed${speed}
Route${note}

Illustrative demo vessel — not live AIS.

` + ); + } + + // 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: `

Live AIS — MMSI ${mmsi}

`, + }); + 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' }; +}