From a8b7680c7ae6453cb2d4c864ed976baf1af9b41d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:27:33 +0000 Subject: [PATCH] feat(mobile): add the phone-tuned Cesium globe engine createGlobe() implements the Globe API in mobile/DESIGN.md: Esri day imagery blended with NASA Black Marble night lights across the terminator, Google 3D tiles below 25 km when keyed, a single-pass grade with half-res bloom and lenses, an adaptive quality governor, live layers on the repo's /api routes and parsers, time scrubbing, picking, AR contact queries and live situation cards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH --- mobile/globe/contacts.js | 126 +++++ mobile/globe/dev.css | 45 ++ mobile/globe/dev.html | 20 + mobile/globe/dev.js | 61 +++ mobile/globe/feeds.js | 687 +++++++++++++++++++++++++ mobile/globe/geo.js | 116 +++++ mobile/globe/glyphs.js | 253 +++++++++ mobile/globe/imagery.js | 215 ++++++++ mobile/globe/index.js | 915 +++++++++++++++++++++++++++++++++ mobile/globe/layers/air.js | 287 +++++++++++ mobile/globe/layers/base.js | 384 ++++++++++++++ mobile/globe/layers/catalog.js | 95 ++++ mobile/globe/layers/hazards.js | 622 ++++++++++++++++++++++ mobile/globe/layers/orbit.js | 435 ++++++++++++++++ mobile/globe/layers/sea.js | 262 ++++++++++ mobile/globe/places.js | 288 +++++++++++ mobile/globe/post.js | 289 +++++++++++ mobile/globe/quality.js | 150 ++++++ mobile/globe/situations.js | 566 ++++++++++++++++++++ 19 files changed, 5816 insertions(+) create mode 100644 mobile/globe/contacts.js create mode 100644 mobile/globe/dev.css create mode 100644 mobile/globe/dev.html create mode 100644 mobile/globe/dev.js create mode 100644 mobile/globe/feeds.js create mode 100644 mobile/globe/geo.js create mode 100644 mobile/globe/glyphs.js create mode 100644 mobile/globe/imagery.js create mode 100644 mobile/globe/index.js create mode 100644 mobile/globe/layers/air.js create mode 100644 mobile/globe/layers/base.js create mode 100644 mobile/globe/layers/catalog.js create mode 100644 mobile/globe/layers/hazards.js create mode 100644 mobile/globe/layers/orbit.js create mode 100644 mobile/globe/layers/sea.js create mode 100644 mobile/globe/places.js create mode 100644 mobile/globe/post.js create mode 100644 mobile/globe/quality.js create mode 100644 mobile/globe/situations.js diff --git a/mobile/globe/contacts.js b/mobile/globe/contacts.js new file mode 100644 index 000000000..122927e35 --- /dev/null +++ b/mobile/globe/contacts.js @@ -0,0 +1,126 @@ +/** + * contactsNear(): aircraft, satellites and ships around a point, for the AR + * Look mode. Reads the shared feeds directly (not layer state), so AR works + * whether or not those layers are on the globe, and nudges a stale feed to + * refresh in the background instead of blocking the caller's frame. + */ +import { twoline2satrec } from 'satellite.js'; +import { satPosition } from './layers/orbit.js'; +import { haversineKm, offsetLonLat } from './geo.js'; + +const SAT_GROUPS = ['stations', 'visual']; + +export function createContactIndex(feeds, { active }) { + let satCache = { stamp: '', sats: [] }; + + function satrecs() { + const stamp = SAT_GROUPS.map((g) => feeds[g].updatedAt).join(':'); + if (stamp === satCache.stamp) return satCache.sats; + const seen = new Set(); + const sats = []; + for (const g of SAT_GROUPS) { + for (const e of feeds[g].data?.entries || []) { + const id = e.line1.slice(2, 7).trim(); + if (seen.has(id)) continue; + seen.add(id); + try { + sats.push({ + id, + name: e.name.trim(), + satrec: twoline2satrec(e.line1, e.line2), + }); + } catch { + /* skip malformed */ + } + } + } + satCache = { stamp, sats }; + return sats; + } + + function nudge(feed, maxAgeMs) { + if (!active()) return; + if (!feed.data || Date.now() - feed.updatedAt > maxAgeMs) + feed.get({ maxAgeMs }).catch(() => {}); + } + + return function contactsNear( + lon, + lat, + km = 50, + { timeMs = Date.now() } = {}, + ) { + nudge(feeds.flights, 20_000); + nudge(feeds.military, 30_000); + nudge(feeds.vessels, 60_000); + for (const g of SAT_GROUPS) nudge(feeds[g], 6 * 3600_000); + + const out = []; + const seen = new Set(); + const now = Date.now(); + const addMover = (r, kind, title) => { + if (seen.has(r.id)) return; + let [rlon, rlat] = [r.lon, r.lat]; + if (r.speed && !r.onGround && r.seenAt) { + const dt = Math.min(90, Math.max(0, (now - r.seenAt) / 1000)); + [rlon, rlat] = offsetLonLat(r.lon, r.lat, r.heading || 0, r.speed * dt); + } + const d = haversineKm(lat, lon, rlat, rlon); + if (d > km) return; + seen.add(r.id); + out.push({ + kind, + id: r.id, + title, + lon: rlon, + lat: rlat, + altM: r.altM ?? 0, + headingDeg: r.heading ?? null, + speedMps: r.speed ?? null, + distanceKm: d, + }); + }; + for (const r of feeds.military.data?.records || []) + addMover(r, r.helicopter ? 'helicopter' : 'military', r.callsign); + for (const r of feeds.flights.data?.records || []) + addMover(r, r.helicopter ? 'helicopter' : 'aircraft', r.callsign); + for (const r of feeds.vessels.data?.records || []) + addMover( + { ...r, altM: 0, seenAt: feeds.vessels.updatedAt }, + 'vessel', + r.name, + ); + + // Satellites: anything whose sub-point is within km plus the horizon + // reach of its altitude is potentially overhead. + for (const s of satrecs()) { + const p = satPosition(s.satrec, timeMs); + if (!p) continue; + const reachKm = km + Math.sqrt(2 * 6371 * (p.altM / 1000)); + const d = haversineKm(lat, lon, p.lat, p.lon); + if (d > reachKm) continue; + const q = satPosition(s.satrec, timeMs + 10_000); + const heading = q + ? (Math.atan2( + (q.lon - p.lon) * Math.cos((p.lat * Math.PI) / 180), + q.lat - p.lat, + ) * + 180) / + Math.PI + + 360 + : 0; + out.push({ + kind: s.id === '25544' ? 'station' : 'satellite', + id: s.id, + title: s.name, + lon: p.lon, + lat: p.lat, + altM: p.altM, + headingDeg: heading % 360, + speedMps: p.speedMps, + distanceKm: d, + }); + } + return out.sort((a, b) => a.distanceKm - b.distanceKm); + }; +} diff --git a/mobile/globe/dev.css b/mobile/globe/dev.css new file mode 100644 index 000000000..2444a9463 --- /dev/null +++ b/mobile/globe/dev.css @@ -0,0 +1,45 @@ +html, +body { + margin: 0; + height: 100%; + background: #05070b; + color: #e8eef7; + font: + 12px/1.4 system-ui, + sans-serif; + overflow: hidden; +} +#globe { + position: fixed; + inset: 0; +} +#hud { + position: fixed; + left: 8px; + bottom: 8px; + max-width: 70vw; + padding: 6px 8px; + border-radius: 10px; + background: rgba(5, 7, 11, 0.55); + font-variant-numeric: tabular-nums; + pointer-events: none; + white-space: pre-line; +} +#hud:empty { + display: none; +} +#labels { + position: fixed; + inset: 0; + pointer-events: none; +} +#labels span { + position: absolute; + transform: translate(10px, -50%); + padding: 1px 5px; + border-radius: 6px; + background: rgba(5, 7, 11, 0.55); + font-size: 10px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} diff --git a/mobile/globe/dev.html b/mobile/globe/dev.html new file mode 100644 index 000000000..90d56fe7a --- /dev/null +++ b/mobile/globe/dev.html @@ -0,0 +1,20 @@ + + + + + + + + Globe engine · dev + + + +
+
+
+ + + diff --git a/mobile/globe/dev.js b/mobile/globe/dev.js new file mode 100644 index 000000000..201ac5977 --- /dev/null +++ b/mobile/globe/dev.js @@ -0,0 +1,61 @@ +// Dev harness: mounts the globe full-screen and exposes it as window.globe. +// Query: ?quality=auto|low|high&lens=cinematic&layers=flights,iss&hud=0 +import { createGlobe } from './index.js'; + +const params = new URLSearchParams(location.search); +const hud = document.getElementById('hud'); +const showHud = params.get('hud') !== '0'; + +const globe = await createGlobe(document.getElementById('globe'), { + quality: params.get('quality') || 'auto', + lens: params.get('lens') || 'cinematic', + layers: params.get('layers')?.split(',').filter(Boolean), + preserveDrawingBuffer: params.has('capture'), +}); +window.globe = globe; + +let quality = globe.quality(); +let lastPick = null; +const render = () => { + if (!showHud) return; + const on = globe + .layers() + .filter((l) => l.on) + .map((l) => `${l.id} ${l.count} ${l.status}`) + .join('\n'); + hud.textContent = [ + `scale ${quality.scale} · ${quality.fps} fps · bloom ${quality.bloom ? 'on' : 'off'}`, + on, + lastPick ? `▸ ${lastPick.title} — ${lastPick.subtitle}` : '', + ] + .filter(Boolean) + .join('\n'); +}; +globe.on('quality', (q) => { + quality = q; + render(); +}); +globe.on('layers', render); +globe.on('pick', (p) => { + lastPick = p; + render(); +}); +render(); + +// HTML labels (§5): at most 40, refreshed with the camera and once a second. +const labelLayer = document.getElementById('labels'); +const drawLabels = () => { + labelLayer.replaceChildren( + ...globe.labels(40).map((l) => { + const el = document.createElement('span'); + el.textContent = l.title; + el.style.left = `${l.x}px`; + el.style.top = `${l.y}px`; + return el; + }), + ); +}; +if (params.get('labels') !== '0') { + globe.on('camera', drawLabels); + setInterval(drawLabels, 1000); +} diff --git a/mobile/globe/feeds.js b/mobile/globe/feeds.js new file mode 100644 index 000000000..1c03c4a41 --- /dev/null +++ b/mobile/globe/feeds.js @@ -0,0 +1,687 @@ +/** + * Shared, cached data feeds. Layers and situations read the same feed objects, + * so a situation card never triggers a second download of data a visible layer + * already holds, and a layer that is off costs nothing until something asks. + * + * Every feed resolves to `{ data, stale }` from its loader; `get()` returns the + * cached data when it is younger than `maxAgeMs`, otherwise loads once (single + * flight) and keeps the last good data when a refresh fails. + */ +import { parseTleText } from '../../src/sources/tle.js'; +import { normalizeEarthquakeSnapshot } from '../../src/layers/earthquakes/records.js'; +import { adaptFirmsRecords } from '../../src/data/firmsAdapt.js'; +import { validateCycloneSnapshot } from '../../src/layers/cyclones/source.js'; +import { classifyAircraft } from '../../src/data/aircraftClass.js'; +import { BHOTE_KOSHI_FLOOD_PATH } from '../../src/data/bhoteKoshiFloodPath.js'; +import { FT_TO_M, KT_TO_MPS, finite } from './geo.js'; + +const FETCH_TIMEOUT_MS = 25_000; + +async function fetchWithTimeout(url, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +export class FeedError extends Error { + constructor(message, { keyRequired = false, status = 0 } = {}) { + super(message); + this.keyRequired = keyRequired; + this.status = status; + } +} + +/** A cached single-flight feed. */ +export function createFeed(name, load, { ttlMs = 60_000 } = {}) { + const listeners = new Set(); + const feed = { + name, + data: null, + stale: false, + updatedAt: 0, + error: null, + async get({ maxAgeMs = ttlMs, args } = {}) { + if (feed.data && Date.now() - feed.updatedAt < maxAgeMs) return feed.data; + // A server without the feed's key is not asked again for 10 minutes. + if (feed.error?.keyRequired && Date.now() < feed._retryAt) { + if (feed.data) return feed.data; + throw feed.error; + } + if (!feed._inflight) { + feed._inflight = (async () => { + try { + const result = await load(args); + feed.data = result.data; + feed.stale = Boolean(result.stale); + feed.updatedAt = Date.now(); + feed.error = null; + } catch (error) { + feed.error = error; + feed._retryAt = Date.now() + 10 * 60_000; + if (feed.data) feed.stale = true; + } finally { + feed._inflight = null; + for (const fn of listeners) fn(feed); + } + })(); + } + await feed._inflight; + if (!feed.data && feed.error) throw feed.error; + return feed.data; + }, + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + _inflight: null, + _retryAt: 0, + }; + return feed; +} + +/* ───────────────────────── Aircraft ───────────────────────── */ + +const REGISTRATION_CALLSIGN = + /^(N\d{1,5}[A-Z]{0,2}|[CDFGI]-?[A-Z]{4}|VH-?[A-Z]{3}|D[A-Z]{4}|ZK-?[A-Z]{3}|JA\d{3,4}[A-Z]?)$/; + +/** OpenSky state vector → flight record. */ +function flightFromState(s) { + const lon = finite(s[5]); + const lat = finite(s[6]); + if (lon === null || lat === null) return null; + const callsign = String(s[1] || '').trim(); + const category = finite(s[17]); + const cls = classifyAircraft({ category: category ?? undefined }); + const squawk = s[14] ? String(s[14]).trim() : null; + const altM = finite(s[13]) ?? finite(s[7]); + const onGround = s[8] === true; + return { + id: String(s[0]), + callsign: callsign || String(s[0]).toUpperCase(), + country: s[2] || '', + lon, + lat, + altM: onGround ? 0 : (altM ?? 10_000), + baroAltM: finite(s[7]), + onGround, + speed: finite(s[9]), + heading: finite(s[10]) ?? 0, + vRate: finite(s[11]), + squawk, + category, + cls, + ga: + category === 2 || + category === 3 || + REGISTRATION_CALLSIGN.test(callsign.replace(/\s+/g, '')), + helicopter: category === 8 || cls === 'helicopter', + emergency: squawk === '7500' || squawk === '7600' || squawk === '7700', + seenAt: (finite(s[4]) ?? Date.now() / 1000) * 1000, + }; +} + +export function createFlightsFeed(getAnchor) { + return createFeed( + 'flights', + async () => { + const anchor = getAnchor?.(); + const query = anchor + ? `?lat=${anchor.lat.toFixed(3)}&lon=${anchor.lon.toFixed(3)}` + : ''; + const response = await fetchWithTimeout(`/api/flights${query}`, { + cache: 'no-store', + }); + if (!response.ok) + throw new FeedError(`Flights HTTP ${response.status}`, { + status: response.status, + }); + const payload = await response.json(); + if (!Array.isArray(payload?.states)) + throw new FeedError('Malformed flight snapshot'); + const records = []; + for (const s of payload.states) { + const r = flightFromState(s); + if (r) records.push(r); + } + const cache = response.headers.get('x-opensky-cache') || ''; + // When OpenSky is down the proxy answers with a 250 nm adsb.lol + // snapshot around the anchor: real, but regional. + const regional = /adsb\.lol/i.test( + response.headers.get('x-flight-source') || '', + ); + const source = regional + ? 'adsb.lol (regional fallback, 250 nm)' + : 'OpenSky Network'; + return { + data: { + records, + source, + regional, + anchor: anchor || null, + time: (payload.time || 0) * 1000, + }, + stale: cache === 'STALE', + }; + }, + { ttlMs: 12_000 }, + ); +} + +export function createMilitaryFeed() { + return createFeed( + 'military', + async () => { + const response = await fetchWithTimeout('/api/military', { + cache: 'no-store', + }); + if (!response.ok) + throw new FeedError(`Military HTTP ${response.status}`, { + status: response.status, + }); + const payload = await response.json(); + if (!Array.isArray(payload?.ac)) + throw new FeedError('Malformed military snapshot'); + const records = []; + for (const a of payload.ac) { + const lon = finite(a.lon); + const lat = finite(a.lat); + if (lon === null || lat === null) continue; + const onGround = a.alt_baro === 'ground'; + const altFt = finite(a.alt_geom) ?? finite(a.alt_baro); + const cls = classifyAircraft({ typeCode: a.t, category: a.category }); + records.push({ + id: String(a.hex), + callsign: String(a.flight || a.r || a.hex).trim(), + registration: a.r || '', + type: a.t || '', + lon, + lat, + altM: onGround ? 0 : Math.max(0, (altFt ?? 15_000) * FT_TO_M), + onGround, + speed: finite(a.gs) !== null ? a.gs * KT_TO_MPS : null, + heading: finite(a.track) ?? finite(a.true_heading) ?? 0, + squawk: a.squawk || null, + cls, + helicopter: cls === 'helicopter', + military: true, + }); + } + return { + data: { records, source: 'adsb.lol' }, + stale: response.headers.get('x-feed-cache') === 'STALE', + }; + }, + { ttlMs: 15_000 }, + ); +} + +/* ───────────────────────── Sea ───────────────────────── */ + +export function createVesselsFeed() { + return createFeed( + 'vessels', + async () => { + const response = await fetchWithTimeout('/api/vessels?maxRows=4000', { + cache: 'no-store', + }); + let payload = null; + try { + payload = await response.json(); + } catch { + /* status decides */ + } + if (payload?.status === 'missing-key') + throw new FeedError('AIS needs AISSTREAM_API_KEY on the server', { + keyRequired: true, + status: response.status, + }); + if (!response.ok || !Array.isArray(payload?.rows)) + throw new FeedError(`Vessels HTTP ${response.status}`, { + status: response.status, + }); + const records = []; + for (const row of payload.rows) { + const lat = finite(row.lat); + const lon = finite(row.lon); + if (lat === null || lon === null) continue; + records.push({ + id: String(row.mmsi || `${lat},${lon}`), + name: String(row.name || row.mmsi || 'Vessel'), + type: String(row.type || ''), + destination: String(row.destination || ''), + lon, + lat, + speed: finite(row.speed) !== null ? row.speed * KT_TO_MPS : null, + heading: finite(row.heading) ?? finite(row.course) ?? 0, + }); + } + return { + data: { records, source: payload.source || 'AISStream' }, + stale: payload.status === 'stale', + }; + }, + { ttlMs: 20_000 }, + ); +} + +/* ───────────────────────── Orbit ───────────────────────── */ + +export function createTleFeed(group, ttlMs = 2 * 3600_000) { + return createFeed( + `tle:${group}`, + async () => { + const response = await fetchWithTimeout(`/api/celestrak/${group}`); + if (!response.ok) + throw new FeedError(`CelesTrak HTTP ${response.status}`, { + status: response.status, + }); + const entries = parseTleText(await response.text()); + if (!entries.length) throw new FeedError('Empty TLE catalog'); + return { + data: { entries, source: 'CelesTrak' }, + stale: response.headers.get('x-tle-cache') === 'STALE-ERROR', + }; + }, + { ttlMs }, + ); +} + +function launchRecord(r, upcoming) { + const lat = finite(r?.pad?.latitude); + const lon = finite(r?.pad?.longitude); + const net = Date.parse(r?.net); + if (lat === null || lon === null || !Number.isFinite(net)) return null; + return { + id: String(r.id), + name: String(r.name || 'Launch'), + net, + status: r.status?.abbrev || r.status?.name || '', + provider: r.launch_service_provider?.name || r.lsp_name || '', + pad: r.pad?.name || '', + location: r.pad?.location?.name || r.location || '', + mission: r.mission?.name || '', + orbit: r.mission?.orbit?.abbrev || '', + image: r.image?.thumbnail_url || r.image?.image_url || null, + lon, + lat, + upcoming, + }; +} + +/** + * Recent launches from the app's proxy plus upcoming launches straight from + * Launch Library 2 (keyless, CORS-enabled, rate limited to ~15/h, so the + * feed TTL is long). + */ +export function createLaunchesFeed() { + return createFeed( + 'launches', + async () => { + const [recent, upcoming] = await Promise.allSettled([ + fetchWithTimeout('/api/launches').then((r) => + r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), + ), + fetchWithTimeout( + 'https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=12&mode=normal', + ).then((r) => + r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), + ), + ]); + const records = new Map(); + const add = (list, isUpcoming) => { + for (const raw of list || []) { + const rec = launchRecord(raw, isUpcoming); + if (rec) records.set(rec.id, rec); + } + }; + if (recent.status === 'fulfilled') + add(recent.value?.results ?? recent.value, false); + if (upcoming.status === 'fulfilled') add(upcoming.value?.results, true); + if (!records.size) throw new FeedError('Launch feeds unavailable'); + const now = Date.now(); + for (const r of records.values()) r.upcoming = r.net > now - 3600_000; + return { + data: { + records: [...records.values()].sort((a, b) => a.net - b.net), + source: 'The Space Devs · Launch Library 2', + upcomingOk: upcoming.status === 'fulfilled', + }, + stale: upcoming.status !== 'fulfilled', + }; + }, + { ttlMs: 30 * 60_000 }, + ); +} + +/* ───────────────────────── Hazards ───────────────────────── */ + +export function createQuakesFeed() { + return createFeed( + 'earthquakes', + async () => { + const response = await fetchWithTimeout( + 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_week.geojson', + ); + if (!response.ok) + throw new FeedError(`USGS HTTP ${response.status}`, { + status: response.status, + }); + const rows = normalizeEarthquakeSnapshot(await response.json()); + if (!rows) throw new FeedError('Malformed USGS response'); + return { data: { records: rows, source: 'USGS' } }; + }, + { ttlMs: 5 * 60_000 }, + ); +} + +/** NASA EONET open events: wildfires, volcanoes and severe storms. */ +export function createEonetFeed() { + return createFeed( + 'eonet', + async () => { + // Volcano events are curated slowly (often months old) but stay open, + // so they are asked for without the 45-day window. + const base = 'https://eonet.gsfc.nasa.gov/api/v3/events?status=open'; + const [recent, volcanoes] = await Promise.all([ + fetchWithTimeout(`${base}&days=45&category=wildfires,severeStorms`), + fetchWithTimeout(`${base}&category=volcanoes&limit=80`).catch( + () => null, + ), + ]); + if (!recent.ok) + throw new FeedError(`EONET HTTP ${recent.status}`, { + status: recent.status, + }); + const events = [...((await recent.json())?.events || [])]; + if (volcanoes?.ok) + events.push(...((await volcanoes.json())?.events || [])); + const out = { wildfires: [], volcanoes: [], severeStorms: [] }; + for (const e of events) { + const cat = e.categories?.[0]?.id; + if (!out[cat]) continue; + const points = (e.geometry || []) + .filter((g) => g.type === 'Point' && Array.isArray(g.coordinates)) + .map((g) => ({ + lon: g.coordinates[0], + lat: g.coordinates[1], + t: Date.parse(g.date), + magnitude: finite(g.magnitudeValue), + unit: g.magnitudeUnit || '', + })) + .filter((p) => Number.isFinite(p.lon) && Number.isFinite(p.lat)) + .sort((a, b) => a.t - b.t); + if (!points.length) continue; + const last = points.at(-1); + out[cat].push({ + id: e.id, + title: e.title, + lon: last.lon, + lat: last.lat, + t: last.t, + firstT: points[0].t, + magnitude: last.magnitude, + unit: last.unit, + track: points, + link: e.sources?.[0]?.url || e.link, + }); + } + return { data: { ...out, source: 'NASA EONET' } }; + }, + { ttlMs: 20 * 60_000 }, + ); +} + +/** + * Active fires: NASA FIRMS hotspots through the app's proxy when a MAP_KEY is + * configured, else NASA EONET wildfire events (keyless) so the layer still + * says something true. + */ +export function createFiresFeed(eonet) { + return createFeed( + 'fires', + async () => { + let keyRequired = false; + try { + // Ask the proxy whether it has a MAP_KEY before asking for fires, + // so a keyless server costs one quiet 200 instead of a 503. + const status = await fetchWithTimeout('/api/firms/status', { + cache: 'no-store', + }).then((r) => (r.ok ? r.json() : null)); + keyRequired = status?.hasKey === false; + if (!keyRequired) { + const response = await fetchWithTimeout('/api/firms', { + cache: 'no-store', + }); + const payload = await response.json().catch(() => null); + if (response.ok && Array.isArray(payload?.fires)) { + const records = adaptFirmsRecords(payload.fires).map((f) => ({ + id: `firms-${f.index}`, + lon: f.lon, + lat: f.lat, + t: f.acqMs, + frp: f.frp, + confidence: f.confidence, + title: 'Fire hotspot', + })); + return { + data: { records, source: 'NASA FIRMS', mode: 'hotspots' }, + stale: Boolean(payload.stale), + }; + } + keyRequired = payload?.error === 'no_key'; + } + } catch { + /* fall through to EONET */ + } + const ev = await eonet.get(); + const records = ev.wildfires.map((f) => ({ + id: f.id, + lon: f.lon, + lat: f.lat, + t: f.t, + frp: null, + acres: f.unit === 'acres' ? f.magnitude : null, + title: f.title, + link: f.link, + })); + return { + data: { + records, + source: 'NASA EONET wildfire events', + mode: 'events', + firmsKeyRequired: keyRequired, + }, + }; + }, + { ttlMs: 10 * 60_000 }, + ); +} + +export function createCyclonesFeed(eonet) { + return createFeed( + 'cyclones', + async () => { + const storms = []; + let source = 'NOAA NHC / CPHC'; + let nhcOk = false; + try { + const response = await fetchWithTimeout('/api/cyclones'); + if (response.ok) { + const raw = await response.json(); + let snap; + try { + snap = validateCycloneSnapshot(raw); + } catch { + snap = raw; + } + nhcOk = true; + for (const s of snap.storms || []) { + const lon = s.position?.longitude; + const lat = s.position?.latitude; + if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; + storms.push({ + id: s.id, + name: s.name, + classification: s.classification, + basin: s.basin, + lon, + lat, + t: Date.parse(s.positionAt) || Date.now(), + windKt: s.windKt, + pressureHpa: s.pressureHpa, + movement: s.movement, + forecast: (s.forecastPoints || []).map((p) => ({ + lon: p.position.longitude, + lat: p.position.latitude, + tauHours: p.tauHours, + windKt: p.windKt, + })), + cone: s.cone, + track: s.track, + link: s.advisoryUrl, + source: 'NOAA NHC', + }); + } + } + } catch { + /* EONET below */ + } + // EONET severe storms add the basins NHC does not cover (W. Pacific, + // Indian Ocean, Southern Hemisphere). + try { + const ev = await eonet.get(); + for (const s of ev.severeStorms) { + if (Date.now() - s.t > 3 * 86_400_000) continue; + const dup = storms.some( + (n) => Math.abs(n.lat - s.lat) < 3 && Math.abs(n.lon - s.lon) < 3, + ); + if (dup) continue; + const nameMatch = + /(Super Typhoon|Typhoon|Hurricane|Tropical Cyclone|Cyclone|Tropical Storm|Tropical Depression|Storm|Depression)\s+([\w-]+)/i.exec( + s.title, + ); + storms.push({ + id: s.id, + name: nameMatch ? nameMatch[2] : s.title, + label: nameMatch + ? nameMatch[1].replace(/\b\w/g, (c) => c.toUpperCase()) + : null, + classification: /typhoon/i.test(s.title) + ? 'TY' + : /cyclone/i.test(s.title) + ? 'TC' + : 'TS', + basin: '', + title: s.title, + lon: s.lon, + lat: s.lat, + t: s.t, + windKt: s.unit === 'kts' ? s.magnitude : null, + pastTrack: s.track, + forecast: [], + link: s.link, + source: 'NASA EONET', + }); + } + if (!nhcOk) source = 'NASA EONET'; + else source = 'NOAA NHC / CPHC + NASA EONET'; + } catch { + if (!nhcOk) throw new FeedError('Cyclone feeds unavailable'); + } + return { data: { records: storms, source } }; + }, + { ttlMs: 15 * 60_000 }, + ); +} + +/* ───────────────────────── Eyes ───────────────────────── */ + +export function createCctvFeed() { + return createFeed( + 'cctv', + async () => { + const response = await fetchWithTimeout('/api/cctv/sources', { + cache: 'no-store', + }); + if (!response.ok) + throw new FeedError(`CCTV HTTP ${response.status}`, { + status: response.status, + }); + const payload = await response.json(); + if (!Array.isArray(payload?.sources)) + throw new FeedError('Malformed camera catalog'); + const records = []; + for (const c of payload.sources) { + const lat = finite(c.lat); + const lon = finite(c.lon); + if (lat === null || lon === null) continue; + records.push({ + id: String(c.id), + name: String(c.name || c.code || 'Camera'), + city: c.city || '', + provider: c.provider || '', + lon, + lat, + heading: finite(c.headingDeg) ?? 0, + fovDeg: finite(c.fovDeg) ?? 50, + pitchDeg: finite(c.pitchDeg) ?? -10, + feedType: c.feedType || 'image', + }); + } + return { data: { records, source: 'Public traffic cameras' } }; + }, + { ttlMs: 30 * 60_000 }, + ); +} + +/** Same frame URL family the production CCTV layer uses. */ +export function cctvFrameUrl(camera) { + const params = new URLSearchParams({ + label: camera.name, + city: camera.city, + lat: camera.lat.toFixed(6), + lon: camera.lon.toFixed(6), + heading: String(Math.round(camera.heading)), + fov: String(Math.round(camera.fovDeg)), + pitch: String(Math.round(camera.pitchDeg || -10)), + ts: String(Math.floor(Date.now() / 10_000)), + }); + return `/api/cctv/frame/${encodeURIComponent(camera.id)}?${params}`; +} + +/* ───────────────────────── Bundled event ───────────────────────── */ + +export function createFloodFeed() { + return createFeed( + 'bhote-koshi', + async () => { + let event = null; + try { + const response = await fetchWithTimeout( + '/events/bhote-koshi-2026/event.json', + ); + if (response.ok) event = await response.json(); + } catch { + /* the bundled centerline below still renders */ + } + const corridor = event?.reconstruction?.corridor?.map((c) => [ + c.lon, + c.lat, + c.elevationM, + ]); + return { + data: { + event, + path: BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => [lon, lat]), + corridor: corridor?.length ? corridor : null, + source: 'GeoPera river centerline (CC BY-NC 4.0) · Vantor Open Data', + }, + }; + }, + { ttlMs: Infinity }, + ); +} diff --git a/mobile/globe/geo.js b/mobile/globe/geo.js new file mode 100644 index 000000000..3db037ec9 --- /dev/null +++ b/mobile/globe/geo.js @@ -0,0 +1,116 @@ +/** + * Small, dependency-free geo helpers shared by the mobile globe modules. + */ + +export const D2R = Math.PI / 180; +export const R2D = 180 / Math.PI; +export const EARTH_RADIUS_KM = 6371.0088; +export const KT_TO_MPS = 1852 / 3600; +export const FT_TO_M = 0.3048; + +/** Great-circle distance in km. */ +export function haversineKm(lat1, lon1, lat2, lon2) { + const dLat = (lat2 - lat1) * D2R; + const dLon = (lon2 - lon1) * D2R; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * D2R) * Math.cos(lat2 * D2R) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a))); +} + +/** Move a point `distM` metres along `headingDeg` (flat-earth, fine for < 50 km). */ +export function offsetLonLat(lon, lat, headingDeg, distM) { + const h = headingDeg * D2R; + const dLat = (distM * Math.cos(h)) / 111_320; + const dLon = + (distM * Math.sin(h)) / (111_320 * Math.max(0.05, Math.cos(lat * D2R))); + return [wrapLon(lon + dLon), Math.max(-89.9, Math.min(89.9, lat + dLat))]; +} + +export function wrapLon(lon) { + return ((((lon + 180) % 360) + 360) % 360) - 180; +} + +export const finite = (value) => + value !== null && value !== '' && Number.isFinite(Number(value)) + ? Number(value) + : null; + +/** A bounding box of `radiusKm` around a point, as [west, south, east, north]. */ +export function bboxAround(lon, lat, radiusKm, aspect = 0.75) { + const dLat = radiusKm / 111.32; + const dLon = + (radiusKm * aspect) / (111.32 * Math.max(0.05, Math.cos(lat * D2R))); + return [lon - dLon, lat - dLat, lon + dLon, lat + dLat]; +} + +/** + * Keyless Esri World Imagery still for a bbox (portrait 600x800 by default). + * The export endpoint widens the bbox to the image aspect in Web Mercator, so + * callers pass a roughly matching bbox to keep the subject centred. + */ +export function esriThumbnail( + [west, south, east, north], + { width = 600, height = 800 } = {}, +) { + const bbox = [west, south, east, north].map((v) => v.toFixed(5)).join(','); + return ( + 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export' + + `?bbox=${bbox}&bboxSR=4326&imageSR=3857&size=${width},${height}&format=jpg&f=image` + ); +} + +/** Thumbnail framing a radius around a point. */ +export function thumbnailAround(lon, lat, radiusKm) { + return esriThumbnail(bboxAround(lon, lat, radiusKm)); +} + +/** Sub-solar point for a UTC instant (low-precision, ~0.1 deg). */ +export function subsolarPoint(ms) { + const d = (ms - Date.UTC(2000, 0, 1, 12)) / 86_400_000; + const g = (357.529 + 0.98560028 * d) * D2R; + const q = 280.459 + 0.98564736 * d; + const L = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D2R; + const e = (23.439 - 0.00000036 * d) * D2R; + const dec = Math.asin(Math.sin(e) * Math.sin(L)) * R2D; + const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L)) * R2D; + const gmst = (18.697374558 + 24.06570982441908 * d) % 24; + const lon = wrapLon(ra - gmst * 15); + return { lon, lat: dec }; +} + +/** Compact relative time, e.g. "3 h ago" / "in 2 d". */ +export function relTime(ms, now = Date.now()) { + const diff = ms - now; + const abs = Math.abs(diff); + const unit = + abs < 3_600_000 + ? [Math.max(1, Math.round(abs / 60_000)), 'min'] + : abs < 172_800_000 + ? [Math.round(abs / 3_600_000), 'h'] + : [Math.round(abs / 86_400_000), 'd']; + return diff < 0 ? `${unit[0]} ${unit[1]} ago` : `in ${unit[0]} ${unit[1]}`; +} + +/** Bin points to a grid of `cellDeg` and return the densest cell. */ +export function densestCell(points, cellDeg = 2) { + const bins = new Map(); + for (const p of points) { + const key = `${Math.floor(p.lat / cellDeg)}:${Math.floor(p.lon / cellDeg)}`; + let bin = bins.get(key); + if (!bin) bins.set(key, (bin = { n: 0, lat: 0, lon: 0, items: [] })); + bin.n++; + bin.lat += p.lat; + bin.lon += p.lon; + bin.items.push(p); + } + let best = null; + for (const bin of bins.values()) if (!best || bin.n > best.n) best = bin; + if (!best) return null; + return { + count: best.n, + lat: best.lat / best.n, + lon: best.lon / best.n, + items: best.items, + }; +} diff --git a/mobile/globe/glyphs.js b/mobile/globe/glyphs.js new file mode 100644 index 000000000..e4ab2fc4d --- /dev/null +++ b/mobile/globe/glyphs.js @@ -0,0 +1,253 @@ +/** + * Small canvas glyphs for billboards. Drawn once in white at 2x, tinted per + * layer through `billboard.color`, and shared through a fixed `imageId` so the + * texture atlas holds each glyph exactly once however many billboards use it. + */ + +const SIZE = 48; // drawn at 2x, displayed at scale 0.5 → 24 css px +const cache = new Map(); + +function canvas(size = SIZE) { + const c = document.createElement('canvas'); + c.width = c.height = size; + return c; +} + +function stroke(ctx, width = 3) { + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.strokeStyle = 'rgba(0,0,0,0.55)'; + ctx.lineWidth = width; + ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.fill(); +} + +const painters = { + dot(ctx, s) { + const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2); + g.addColorStop(0, 'rgba(255,255,255,1)'); + g.addColorStop(0.18, 'rgba(255,255,255,0.95)'); + g.addColorStop(0.4, 'rgba(255,255,255,0.35)'); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, s, s); + }, + plane(ctx) { + ctx.beginPath(); + ctx.moveTo(24, 3); + ctx.lineTo(27.5, 9); + ctx.lineTo(27.5, 19); + ctx.lineTo(44, 29); + ctx.lineTo(44, 33); + ctx.lineTo(27.5, 28); + ctx.lineTo(27, 38); + ctx.lineTo(33, 43); + ctx.lineTo(33, 45.5); + ctx.lineTo(24, 43); + ctx.lineTo(15, 45.5); + ctx.lineTo(15, 43); + ctx.lineTo(21, 38); + ctx.lineTo(20.5, 28); + ctx.lineTo(4, 33); + ctx.lineTo(4, 29); + ctx.lineTo(20.5, 19); + ctx.lineTo(20.5, 9); + ctx.closePath(); + stroke(ctx); + }, + heli(ctx) { + ctx.beginPath(); + ctx.ellipse(24, 22, 6.5, 9, 0, 0, Math.PI * 2); + ctx.rect(22.5, 28, 3, 15); + ctx.rect(18, 41, 12, 3); + stroke(ctx); + ctx.beginPath(); + ctx.lineWidth = 2.5; + ctx.strokeStyle = '#fff'; + ctx.moveTo(7, 7); + ctx.lineTo(41, 37); + ctx.moveTo(41, 7); + ctx.lineTo(7, 37); + ctx.stroke(); + }, + ship(ctx) { + ctx.beginPath(); + ctx.moveTo(24, 4); + ctx.quadraticCurveTo(34, 16, 32, 42); + ctx.lineTo(16, 42); + ctx.quadraticCurveTo(14, 16, 24, 4); + ctx.closePath(); + stroke(ctx); + }, + sat(ctx) { + ctx.beginPath(); + ctx.rect(19, 18, 10, 12); + ctx.rect(3, 20, 14, 8); + ctx.rect(31, 20, 14, 8); + stroke(ctx, 2.5); + }, + iss(ctx) { + ctx.beginPath(); + ctx.rect(21, 14, 6, 20); + ctx.rect(3, 8, 6, 14); + ctx.rect(11, 8, 6, 14); + ctx.rect(31, 8, 6, 14); + ctx.rect(39, 8, 6, 14); + ctx.rect(3, 26, 6, 14); + ctx.rect(11, 26, 6, 14); + ctx.rect(31, 26, 6, 14); + ctx.rect(39, 26, 6, 14); + ctx.rect(9, 22.5, 30, 3); + stroke(ctx, 2); + }, + ring(ctx, s) { + ctx.beginPath(); + ctx.arc(s / 2, s / 2, s / 2 - 5, 0, Math.PI * 2); + ctx.lineWidth = 3; + ctx.strokeStyle = '#fff'; + ctx.shadowColor = 'rgba(0,0,0,0.6)'; + ctx.shadowBlur = 3; + ctx.stroke(); + for (let i = 0; i < 4; i++) { + const a = (i * Math.PI) / 2; + ctx.beginPath(); + ctx.moveTo( + s / 2 + Math.cos(a) * (s / 2 - 1), + s / 2 + Math.sin(a) * (s / 2 - 1), + ); + ctx.lineTo( + s / 2 + Math.cos(a) * (s / 2 - 12), + s / 2 + Math.sin(a) * (s / 2 - 12), + ); + ctx.stroke(); + } + }, + quake(ctx, s) { + for (const [r, a] of [ + [20, 0.45], + [13, 0.8], + ]) { + ctx.beginPath(); + ctx.arc(s / 2, s / 2, r, 0, Math.PI * 2); + ctx.lineWidth = 3; + ctx.strokeStyle = `rgba(255,255,255,${a})`; + ctx.stroke(); + } + ctx.beginPath(); + ctx.arc(s / 2, s / 2, 6, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + }, + fire(ctx) { + ctx.beginPath(); + ctx.moveTo(24, 4); + ctx.bezierCurveTo(34, 16, 40, 24, 36, 34); + ctx.bezierCurveTo(33, 43, 15, 43, 12, 34); + ctx.bezierCurveTo(9, 26, 16, 22, 18, 14); + ctx.bezierCurveTo(21, 20, 24, 20, 24, 4); + ctx.closePath(); + stroke(ctx); + }, + cyclone(ctx, s) { + ctx.lineWidth = 5; + ctx.strokeStyle = '#fff'; + ctx.lineCap = 'round'; + for (let k = 0; k < 2; k++) { + ctx.beginPath(); + for (let t = 0; t <= 1.0001; t += 0.05) { + const a = k * Math.PI + t * Math.PI * 1.25; + const r = 4 + t * 17; + const x = s / 2 + Math.cos(a) * r; + const y = s / 2 + Math.sin(a) * r; + if (t === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + ctx.beginPath(); + ctx.arc(s / 2, s / 2, 5, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + }, + rocket(ctx) { + ctx.beginPath(); + ctx.moveTo(24, 3); + ctx.bezierCurveTo(31, 10, 31, 22, 30, 32); + ctx.lineTo(36, 40); + ctx.lineTo(29, 39); + ctx.lineTo(27, 44); + ctx.lineTo(21, 44); + ctx.lineTo(19, 39); + ctx.lineTo(12, 40); + ctx.lineTo(18, 32); + ctx.bezierCurveTo(17, 22, 17, 10, 24, 3); + ctx.closePath(); + stroke(ctx); + }, + camera(ctx) { + ctx.beginPath(); + ctx.roundRect(7, 15, 26, 18, 4); + ctx.moveTo(33, 20); + ctx.lineTo(42, 15); + ctx.lineTo(42, 33); + ctx.lineTo(33, 28); + ctx.closePath(); + stroke(ctx); + }, + volcano(ctx) { + ctx.beginPath(); + ctx.moveTo(4, 42); + ctx.lineTo(18, 16); + ctx.lineTo(30, 16); + ctx.lineTo(44, 42); + ctx.closePath(); + stroke(ctx); + ctx.beginPath(); + ctx.arc(24, 9, 5, 0, Math.PI * 2); + ctx.fill(); + }, + pin(ctx) { + ctx.beginPath(); + ctx.arc(24, 18, 12, Math.PI, 0); + ctx.bezierCurveTo(36, 28, 28, 34, 24, 45); + ctx.bezierCurveTo(20, 34, 12, 28, 12, 18); + ctx.closePath(); + stroke(ctx); + ctx.beginPath(); + ctx.arc(24, 18, 4.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fill(); + }, + port(ctx) { + ctx.beginPath(); + ctx.arc(24, 10, 5, 0, Math.PI * 2); + ctx.moveTo(24, 15); + ctx.lineTo(24, 42); + ctx.moveTo(14, 22); + ctx.lineTo(34, 22); + ctx.moveTo(8, 30); + ctx.quadraticCurveTo(10, 42, 24, 42); + ctx.quadraticCurveTo(38, 42, 40, 30); + ctx.lineWidth = 7; + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + ctx.lineCap = 'round'; + ctx.stroke(); + ctx.lineWidth = 3.5; + ctx.strokeStyle = '#fff'; + ctx.stroke(); + }, +}; + +/** `{ image, imageId }` for a named glyph. */ +export function glyph(name) { + let entry = cache.get(name); + if (entry) return entry; + const paint = painters[name] || painters.dot; + const size = name === 'dot' ? 32 : SIZE; + const c = canvas(size); + paint(c.getContext('2d'), size); + entry = { image: c, imageId: `gev-mobile-glyph-${name}` }; + cache.set(name, entry); + return entry; +} diff --git a/mobile/globe/imagery.js b/mobile/globe/imagery.js new file mode 100644 index 000000000..2d9407301 --- /dev/null +++ b/mobile/globe/imagery.js @@ -0,0 +1,215 @@ +/** + * Planet surface for the mobile globe: Esri World Imagery by day, NASA Black + * Marble city lights by night (blended across the terminator by Cesium's + * per-layer dayAlpha/nightAlpha with the sun from the scene clock), keyless + * Re:Earth terrain, and Google Photorealistic 3D Tiles below ~25 km when the + * build carries a Google key or Cesium ion token. + */ +import * as Cesium from 'cesium'; +import { createEsriImagery } from '../../src/maps/imagery.js'; +import { createKeylessTerrain } from '../../src/maps/terrain.js'; +import { loadPhotorealisticTileset } from '../../src/maps/google3d.js'; + +/** NASA GIBS VIIRS Black Marble (2016), Web Mercator, keyless, CORS-enabled. */ +export const BLACK_MARBLE_URL = + 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png'; + +export const GOOGLE_3D_MAX_HEIGHT_M = 25_000; +const LIGHT_FULL_BELOW_M = 400_000; +const LIGHT_FULL_ABOVE_M = 2_500_000; +/** Cesium's night side keeps 0.3 of the lit colour (GlobeFS diffuse floor). */ +const NIGHT_DIFFUSE_FLOOR = 0.3; +const NIGHT_LIGHTS_GAIN = 1.3; + +export const BASE_CREDITS = Object.freeze([ + { + id: 'esri', + text: 'Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community', + html: 'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community', + }, + { + id: 'black-marble', + text: 'Night lights: NASA Earth Observatory / GIBS, VIIRS Black Marble', + html: 'Night lights: NASA GIBS VIIRS Black Marble', + }, +]); + +const TERRAIN_CREDIT = { + id: 'terrain', + text: 'Terrain: Re:Earth / Mapterhorn (CC BY 4.0)', + html: 'Terrain: Re:Earth / Mapterhorn, CC BY 4.0', +}; + +export function createImagery(viewer, { terrain = true, google = {} } = {}) { + const scene = viewer.scene; + const globe = scene.globe; + const layers = viewer.imageryLayers; + + const day = Cesium.ImageryLayer.fromProviderAsync(createEsriImagery(), { + dayAlpha: 1, + nightAlpha: 1, + }); + layers.add(day); + + const night = new Cesium.ImageryLayer( + new Cesium.UrlTemplateImageryProvider({ + url: BLACK_MARBLE_URL, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + maximumLevel: 8, + credit: 'NASA GIBS · VIIRS Black Marble', + }), + { + dayAlpha: 0, + nightAlpha: 1, + brightness: NIGHT_LIGHTS_GAIN / NIGHT_DIFFUSE_FLOOR, + contrast: 1.2, + gamma: 0.85, + saturation: 1.15, + }, + ); + layers.add(night); + + globe.enableLighting = true; + globe.dynamicAtmosphereLighting = true; + globe.dynamicAtmosphereLightingFromSun = true; + // Cesium measures these from the Earth's centre. Lighting (and so the + // terminator) is full above ~2,500 km and fades to fully lit below + // ~400 km, where the 500 m night mosaic would only be a blur. + const R = 6_371_000; + globe.lightingFadeOutDistance = R + LIGHT_FULL_BELOW_M; + globe.lightingFadeInDistance = R + LIGHT_FULL_ABOVE_M; + // GlobeFS mixes the surface toward bare ground atmosphere, weighted by + // the sun angle, by k = clamp((dist - nightFadeIn) / (nightFadeOut - + // nightFadeIn), 0.05, 1). Cesium's defaults drive k to 0.05 from orbit, + // which blacks out the night hemisphere and its city lights. Here k is + // ~0.6 from orbit (a soft sun-angle falloff on the day side, lights kept + // on the night side) and 1 below ~3,600 km. nightFadeOut also selects + // per-fragment ground atmosphere above it, so it keeps the default π/2·R. + globe.nightFadeOutDistance = (Math.PI / 2) * R; + globe.nightFadeInDistance = 5.0e7; + globe.baseColor = Cesium.Color.fromCssColorString('#03060b'); + + const state = { + day, + night, + terrainCredit: false, + google: null, + googleRoute: null, + googleLoading: null, + nightMode: false, + heightM: 2e7, + googleWanted: Boolean( + String(google.apiKey || '').trim() || + String(google.ionToken || '').trim(), + ), + }; + + if (terrain) { + createKeylessTerrain().then(({ provider, fallback }) => { + if (viewer.isDestroyed()) return; + viewer.terrainProvider = provider; + state.terrainCredit = !fallback; + scene.requestRender(); + }); + } + + async function ensureGoogle() { + if (!state.googleWanted || state.google || state.googleLoading) return; + state.googleLoading = loadPhotorealisticTileset(Cesium, { + googleApiKey: google.apiKey, + cesiumToken: google.ionToken, + }) + .then(({ tileset, route, errors }) => { + if (!tileset) { + state.googleWanted = false; + console.warn('[globe] Google 3D unavailable', errors); + return; + } + tileset.maximumScreenSpaceError = 16; + tileset.cacheBytes = google.cacheBytes ?? 256 * 1024 * 1024; + tileset.show = false; + scene.primitives.add(tileset); + state.google = tileset; + state.googleRoute = route; + tileset.tileLoad.addEventListener(() => scene.requestRender()); + }) + .catch((error) => { + state.googleWanted = false; + console.warn('[globe] Google 3D failed', error); + }) + .finally(() => { + state.googleLoading = null; + }); + } + + /** + * Keep city lights at a constant apparent brightness while Cesium fades + * lighting out with altitude, and hand the night side back to day imagery + * close in. + */ + function tuneNight() { + if (state.nightMode) return; + const h = state.heightM; + const fade = Math.min( + 1, + Math.max( + 0, + (h - LIGHT_FULL_BELOW_M) / (LIGHT_FULL_ABOVE_M - LIGHT_FULL_BELOW_M), + ), + ); + const diffuse = 1 + (NIGHT_DIFFUSE_FLOOR - 1) * fade; + night.brightness = NIGHT_LIGHTS_GAIN / diffuse; + night.nightAlpha = Math.min(1, Math.max(0, (h - 300_000) / 700_000)); + } + + /** Swap globe ↔ photorealistic tiles by camera height. */ + function onHeight(heightM) { + state.heightM = heightM; + tuneNight(); + if (!state.googleWanted) return; + const close = heightM < GOOGLE_3D_MAX_HEIGHT_M; + if (close) ensureGoogle(); + if (!state.google) return; + if (state.google.show !== close) { + state.google.show = close; + globe.show = !close; + scene.requestRender(); + } + } + + /** Night mode: Black Marble everywhere, unshaded (lighting off). */ + function setNightMode(on) { + state.nightMode = Boolean(on); + globe.enableLighting = !on; + night.dayAlpha = on ? 1 : 0; + night.nightAlpha = 1; + night.brightness = on ? 1.25 : night.brightness; + if (!on) tuneNight(); + scene.requestRender(); + } + + function credits() { + const list = [...BASE_CREDITS]; + if (state.terrainCredit) list.push(TERRAIN_CREDIT); + if (state.google?.show) { + list.push({ + id: 'google', + text: 'Google Photorealistic 3D Tiles', + html: 'Google — 3D Tiles © Google', + onScreen: true, + logo: true, + }); + } + return list; + } + + return { + state, + onHeight, + setNightMode, + credits, + get googleActive() { + return Boolean(state.google?.show); + }, + }; +} diff --git a/mobile/globe/index.js b/mobile/globe/index.js new file mode 100644 index 000000000..d443ebe3b --- /dev/null +++ b/mobile/globe/index.js @@ -0,0 +1,915 @@ +/** + * God's Eye mobile globe engine: createGlobe(container, options) → Globe API + * (mobile/DESIGN.md §10). + * + * Cesium tuned for phones: no widgets, request-render mode (0 fps at rest), + * an adaptive pixel-ratio governor, a single-full-res-pass post chain, and + * live layers drawn with batched billboards and polylines (never Entities, + * never Cesium labels). + */ +import * as Cesium from 'cesium'; +import { createImagery } from './imagery.js'; +import { createPostChain, LENSES } from './post.js'; +import { createGovernor } from './quality.js'; +import { buildLayers, FAMILIES } from './layers/catalog.js'; +import { buildSituations } from './situations.js'; +import { createContactIndex } from './contacts.js'; +import { glyph } from './glyphs.js'; +import { subsolarPoint } from './geo.js'; +import { AIRPORTS } from './places.js'; +import { + createCctvFeed, + createCyclonesFeed, + createEonetFeed, + createFiresFeed, + createFlightsFeed, + createFloodFeed, + createLaunchesFeed, + createMilitaryFeed, + createQuakesFeed, + createTleFeed, + createVesselsFeed, +} from './feeds.js'; +import { applyModelAtmosphereWorkaround } from '../../src/app/atmosphereCompat.js'; + +export { LENSES, FAMILIES }; + +export const DEFAULT_LAYERS = Object.freeze([ + 'flights', + 'iss', + 'earthquakes', + 'cyclones', + 'fires', + 'floods', + 'launches', +]); + +const LABEL_PRIORITY = [ + 'squawk', + 'iss', + 'cyclones', + 'launches', + 'military', + 'earthquakes', + 'floods', + 'volcanoes', + 'helicopters', + 'flights', + 'ga', + 'vessels', + 'tankers', + 'ports', + 'satellites', + 'fires', + 'cctv', + 'starlink', +]; + +const PLANETARY_LABELS = new Set([ + 'squawk', + 'iss', + 'cyclones', + 'launches', + 'earthquakes', + 'floods', +]); + +const DATA_CREDITS = { + flights: 'Flights: OpenSky Network (adsb.lol regional fallback)', + military: 'Military aircraft: adsb.lol (ODbL)', + helicopters: 'Aircraft: OpenSky Network, adsb.lol', + ga: 'Aircraft: OpenSky Network', + squawk: 'Aircraft: OpenSky Network, adsb.lol', + vessels: 'AIS: AISStream.io', + tankers: 'AIS: AISStream.io', + satellites: 'Orbits: CelesTrak GP data, SGP4 via satellite.js', + iss: 'Orbits: CelesTrak GP data', + starlink: 'Orbits: CelesTrak GP data', + launches: 'Launches: The Space Devs · Launch Library 2', + earthquakes: 'Earthquakes: U.S. Geological Survey', + fires: 'Fires: NASA FIRMS / NASA EONET', + cyclones: 'Cyclones: NOAA NHC / CPHC, NASA EONET', + volcanoes: 'Volcanoes: NASA EONET', + floods: + 'Bhote Koshi: GeoPera river centerline (CC BY-NC 4.0), Vantor (Maxar) Open Data', + cctv: 'Cameras: public agency traffic camera feeds (see per-camera provider)', +}; + +function viewerOptions(creditContainer, options) { + return { + animation: false, + baseLayer: false, + baseLayerPicker: false, + fullscreenButton: false, + geocoder: false, + homeButton: false, + infoBox: false, + navigationHelpButton: false, + sceneModePicker: false, + selectionIndicator: false, + timeline: false, + vrButton: false, + creditContainer, + scene3DOnly: true, + requestRenderMode: true, + maximumRenderTimeChange: 60, + msaaSamples: 1, + orderIndependentTranslucency: false, + shadows: false, + useBrowserRecommendedResolution: true, + contextOptions: { + webgl: { + alpha: false, + antialias: false, + depth: true, + stencil: false, + powerPreference: 'high-performance', + preserveDrawingBuffer: Boolean(options.preserveDrawingBuffer), + }, + }, + }; +} + +function tuneScene(viewer) { + const scene = viewer.scene; + applyModelAtmosphereWorkaround(scene); + scene.backgroundColor = Cesium.Color.fromCssColorString('#05070B'); + scene.highDynamicRange = false; + scene.fog.enabled = true; + scene.fog.density = 2.0e-4; + scene.fog.screenSpaceErrorFactor = 4; + scene.sun.show = true; + // Cesium's default sun (intensity 2) clips bright deserts and ice. + scene.light = new Cesium.SunLight({ intensity: 1.4 }); + scene.moon.show = true; + scene.skyBox.show = true; + const sky = scene.skyAtmosphere; + sky.show = true; + sky.atmosphereLightIntensity = 22; + sky.saturationShift = 0.05; + sky.brightnessShift = -0.02; + sky.hueShift = -0.01; + sky.perFragmentAtmosphere = false; // per-vertex is plenty on a phone + const globe = scene.globe; + globe.showGroundAtmosphere = true; + globe.atmosphereLightIntensity = 9; + globe.atmosphereBrightnessShift = 0.0; + globe.atmosphereSaturationShift = 0.08; + globe.showWaterEffect = false; + globe.depthTestAgainstTerrain = false; + globe.preloadSiblings = false; + globe.loadingDescendantLimit = 12; + globe.tileCacheSize = 120; + globe.showSkirts = true; + + const ssc = scene.screenSpaceCameraController; + ssc.inertiaSpin = 0.93; + ssc.inertiaTranslate = 0.92; + ssc.inertiaZoom = 0.82; + ssc.minimumZoomDistance = 120; + ssc.maximumZoomDistance = 45_000_000; + ssc.bounceAnimationTime = 2.2; + ssc.enableCollisionDetection = true; + ssc.maximumMovementRatio = 0.1; +} + +/** + * @param {HTMLElement} container + * @param {{quality?:'auto'|'low'|'high', layers?:string[], lens?:string, + * terrain?:boolean, observer?:{lat:number,lon:number}, + * googleApiKey?:string, cesiumToken?:string, preserveDrawingBuffer?:boolean}} [options] + */ +export async function createGlobe(container, options = {}) { + if (!container) throw new TypeError('createGlobe needs a container element'); + const opts = { + quality: 'auto', + lens: 'cinematic', + terrain: true, + ...options, + }; + + // ── Host & viewer ──────────────────────────────────────────────── + if (getComputedStyle(container).position === 'static') + container.style.position = 'relative'; + const host = document.createElement('div'); + host.className = 'gev-globe'; + host.style.cssText = + 'position:absolute;inset:0;overflow:hidden;touch-action:none;background:#05070B;'; + container.appendChild(host); + const creditContainer = document.createElement('div'); + creditContainer.style.display = 'none'; + + const viewer = new Cesium.Viewer(host, viewerOptions(creditContainer, opts)); + const scene = viewer.scene; + const camera = scene.camera; + const canvas = viewer.canvas; + canvas.style.outline = 'none'; + tuneScene(viewer); + + // ── Events ─────────────────────────────────────────────────────── + const listeners = new Map(); + const emit = (event, payload) => { + for (const fn of listeners.get(event) || []) { + try { + fn(payload); + } catch (error) { + console.error(`[globe] ${event} listener`, error); + } + } + }; + const requestRender = () => { + if (!viewer.isDestroyed()) scene.requestRender(); + }; + + // ── Clock ──────────────────────────────────────────────────────── + const clock = viewer.clock; + let live = true; + clock.clockStep = Cesium.ClockStep.SYSTEM_CLOCK; + clock.shouldAnimate = true; + clock.currentTime = Cesium.JulianDate.now(); + const timeMs = () => Cesium.JulianDate.toDate(clock.currentTime).getTime(); + + // ── Surface, post, quality ─────────────────────────────────────── + const env = import.meta.env || {}; + const imagery = createImagery(viewer, { + terrain: opts.terrain, + google: { + apiKey: opts.googleApiKey ?? env.GOOGLE_MAPS_API_KEY, + ionToken: opts.cesiumToken ?? env.CESIUM_ION_TOKEN, + cacheBytes: + opts.quality === 'low' ? 128 * 1024 * 1024 : 256 * 1024 * 1024, + }, + }); + const post = createPostChain(scene); + const governor = createGovernor(viewer, { + quality: opts.quality, + post, + onChange: (q) => emit('quality', q), + }); + + // ── Feeds & layers ─────────────────────────────────────────────── + let active = true; + let lastView = null; + const eonet = createEonetFeed(); + const feeds = { + flights: createFlightsFeed(() => lastView?.center), + military: createMilitaryFeed(), + vessels: createVesselsFeed(), + visual: createTleFeed('visual'), + stations: createTleFeed('stations'), + gps: createTleFeed('gps-ops'), + starlink: createTleFeed('starlink'), + launches: createLaunchesFeed(), + quakes: createQuakesFeed(), + eonet, + fires: createFiresFeed(eonet), + cyclones: createCyclonesFeed(eonet), + cctv: createCctvFeed(), + flood: createFloodFeed(), + }; + + let layersTimer = null; + const ctx = { + viewer, + scene, + requestRender, + active: () => active && document.visibilityState !== 'hidden', + timeMs, + isLive: () => live, + layersChanged() { + if (layersTimer) return; + layersTimer = setTimeout(() => { + layersTimer = null; + emit('layers', api.layers()); + }, 60); + }, + }; + const layers = buildLayers(ctx, feeds); + + // ── Selection visuals ──────────────────────────────────────────── + const selectionRoot = scene.primitives.add(new Cesium.PrimitiveCollection()); + const ringCollection = selectionRoot.add(new Cesium.BillboardCollection()); + const trailCollection = selectionRoot.add(new Cesium.PolylineCollection()); + const ringGlyph = glyph('ring'); + const ring = ringCollection.add({ + image: ringGlyph.image, + imageId: ringGlyph.imageId, + show: false, + scale: 1, + color: Cesium.Color.fromCssColorString('#FFD166'), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + let selected = null; // { layer, key, pick } + + function drawSelection() { + if (!selected) { + ring.show = false; + trailCollection.removeAll(); + return; + } + const layer = layers.get(selected.layer); + const where = layer?.locate?.(selected.key); + if (!where) { + ring.show = false; + return; + } + ring.position = Cesium.Cartesian3.fromDegrees( + where[0], + where[1], + where[2] || 0, + ); + ring.show = true; + trailCollection.removeAll(); + const trail = layer.trail?.(selected.key) || []; + if (trail.length > 1) { + trailCollection.add({ + positions: trail.map(([lo, la, h]) => + Cesium.Cartesian3.fromDegrees(lo, la, h || 0), + ), + width: 4, + material: Cesium.Material.fromType('PolylineGlow', { + color: Cesium.Color.fromCssColorString('#FFD166').withAlpha(0.8), + glowPower: 0.18, + taperPower: 0.6, + }), + }); + } + } + + function resolveSelectable(id) { + if (id == null) return null; + if (typeof id === 'object' && id.layer) + return { layer: id.layer, key: String(id.id ?? id.key) }; + const s = String(id); + const colon = s.indexOf(':'); + if (colon > 0 && layers.has(s.slice(0, colon))) + return { layer: s.slice(0, colon), key: s.slice(colon + 1) }; + for (const layer of layers.values()) { + if (!layer.on) continue; + if (layer.pick?.(s)) return { layer: layer.id, key: s }; + } + return null; + } + + // ── Camera → LOD, Google 3D, 'camera' events ───────────────────── + const scratchCarto = new Cesium.Cartographic(); + function computeView() { + const heightM = camera.positionCartographic.height; + let center = null; + const mid = new Cesium.Cartesian2( + canvas.clientWidth / 2, + canvas.clientHeight / 2, + ); + const hit = camera.pickEllipsoid(mid, scene.globe.ellipsoid); + if (hit) { + Cesium.Cartographic.fromCartesian( + hit, + scene.globe.ellipsoid, + scratchCarto, + ); + center = { + lon: Cesium.Math.toDegrees(scratchCarto.longitude), + lat: Cesium.Math.toDegrees(scratchCarto.latitude), + }; + } else { + const c = camera.positionCartographic; + center = { + lon: Cesium.Math.toDegrees(c.longitude), + lat: Cesium.Math.toDegrees(c.latitude), + }; + } + let rect = null; + if (heightM < 8_000_000) { + const r = camera.computeViewRectangle(scene.globe.ellipsoid); + if (r) + rect = { + west: Cesium.Math.toDegrees(r.west), + south: Cesium.Math.toDegrees(r.south), + east: Cesium.Math.toDegrees(r.east), + north: Cesium.Math.toDegrees(r.north), + }; + } + return { heightM, center, rect }; + } + + let viewTimer = null; + function updateView() { + viewTimer = null; + lastView = computeView(); + imagery.onHeight(lastView.heightM); + for (const layer of layers.values()) if (layer.on) layer.onView(lastView); + emit('camera', { + lon: lastView.center.lon, + lat: lastView.center.lat, + height: lastView.heightM, + heading: Cesium.Math.toDegrees(camera.heading), + pitch: Cesium.Math.toDegrees(camera.pitch), + }); + requestRender(); + } + function scheduleView() { + if (!viewTimer) viewTimer = setTimeout(updateView, 180); + } + camera.percentageChanged = 0.02; + const removeCameraChanged = camera.changed.addEventListener(scheduleView); + const removeMoveEnd = camera.moveEnd.addEventListener(() => { + clearTimeout(viewTimer); + updateView(); + }); + + // Layers re-read the view whenever they redraw. + const refreshLayerViews = () => { + if (!lastView) lastView = computeView(); + for (const layer of layers.values()) if (layer.on) layer.onView(lastView); + }; + for (const layer of layers.values()) { + if (!layer.available) continue; + const original = layer.refresh.bind(layer); + layer.refresh = async () => { + await original(); + if (lastView) layer.onView(lastView); + if (selected?.layer === layer.id) drawSelection(); + }; + } + + // ── Picking ────────────────────────────────────────────────────── + const handler = new Cesium.ScreenSpaceEventHandler(canvas); + handler.setInputAction((movement) => { + const picked = scene.pick(movement.position, 30, 30); + const id = picked?.id; + if (id && typeof id === 'object' && id.layer && layers.has(id.layer)) { + api.select({ layer: id.layer, id: id.key }); + emit('pick', selected?.pick ?? null); + } else { + if (selected) api.select(null); + emit('pick', null); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + // ── 1 Hz motion tick (dead reckoning, satellites, selection) ───── + const tick = setInterval(() => { + if (!ctx.active()) return; + const now = Date.now(); + const clockMs = timeMs(); + let moved = false; + for (const layer of layers.values()) + if (layer.on && layer.onTick(now, live, clockMs)) moved = true; + if (selected) { + drawSelection(); + moved = true; + } + if (moved) requestRender(); + }, 1000); + + // ── Resize / visibility ────────────────────────────────────────── + const resizeObserver = new ResizeObserver(() => { + viewer.resize(); + scheduleView(); + requestRender(); + }); + resizeObserver.observe(container); + const onVisibility = () => { + if (document.visibilityState === 'visible' && active) { + if (live) clock.currentTime = Cesium.JulianDate.now(); + for (const layer of layers.values()) if (layer.on) layer.kick(); + requestRender(); + } + }; + document.addEventListener('visibilitychange', onVisibility); + + // ── Time ───────────────────────────────────────────────────────── + let timeTimer = null; + function applyTime() { + timeTimer = null; + const ms = timeMs(); + for (const layer of layers.values()) if (layer.on) layer.onTime(ms, live); + if (selected) drawSelection(); + requestRender(); + } + + // ── Camera helpers ─────────────────────────────────────────────── + function groundHeight(lon, lat) { + const h = scene.globe.getHeight(Cesium.Cartographic.fromDegrees(lon, lat)); + return Number.isFinite(h) ? Math.max(0, h) : 0; + } + + function flyTo({ + lon, + lat, + height = 2_000_000, + heading = 0, + pitch = -90, + duration, + targetHeight, + } = {}) { + if (!Number.isFinite(lon) || !Number.isFinite(lat)) + return Promise.reject(new TypeError('flyTo needs lon and lat')); + const reduceMotion = globalThis.matchMedia?.( + '(prefers-reduced-motion: reduce)', + )?.matches; + const seconds = reduceMotion ? 0 : (duration ?? undefined); + const h = Cesium.Math.toRadians(heading); + const p = Cesium.Math.toRadians(Math.max(-90, Math.min(-5, pitch))); + return new Promise((resolve) => { + const done = () => { + updateView(); + resolve(); + }; + if (pitch <= -89.5) { + camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), + orientation: { heading: h, pitch: p, roll: 0 }, + duration: seconds, + complete: done, + cancel: done, + }); + return; + } + const ground = Number.isFinite(targetHeight) + ? targetHeight + : groundHeight(lon, lat); + const range = height / Math.sin(-p); + camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + Cesium.Cartesian3.fromDegrees(lon, lat, ground), + 0, + ), + { + offset: new Cesium.HeadingPitchRange(h, p, range), + duration: seconds, + complete: done, + cancel: done, + }, + ); + requestRender(); + }); + } + + function homeView() { + // Centre just past a terminator so day, dusk and city lights share the + // first frame: of the two terminators, the one whose night side holds + // more of the world's busiest airports (a cheap proxy for city lights). + const sun = subsolarPoint(timeMs()); + const score = (centerLon) => { + let n = 0; + for (const ap of AIRPORTS) { + const fromCenter = Math.abs(((ap.lon - centerLon + 540) % 360) - 180); + const fromSun = Math.abs(((ap.lon - sun.lon + 540) % 360) - 180); + if (fromCenter < 70 && fromSun > 95) n++; + } + return n; + }; + const dusk = ((sun.lon + 62 + 540) % 360) - 180; + const dawn = ((sun.lon - 62 + 540) % 360) - 180; + const lon = score(dusk) >= score(dawn) ? dusk : dawn; + // Fit the disc to ~92 % of the narrow screen dimension, read from the + // live projection (Cesium applies `fov` to the narrower side). + const pm = camera.frustum.projectionMatrix; + const tanHalf = Math.min(1 / pm[0], 1 / pm[5]); + const radius = Math.atan(0.92 * tanHalf); + const height = 6_371_000 / Math.sin(radius) - 6_371_000; + return { + lon, + lat: Math.max(-20, Math.min(30, sun.lat + 15)), + height: Math.max(9_000_000, Math.min(30_000_000, height)), + heading: 0, + pitch: -90, + }; + } + + // ── API ────────────────────────────────────────────────────────── + const contactsNearImpl = createContactIndex(feeds, { active: ctx.active }); + + const api = { + flyTo, + + home({ duration } = {}) { + return flyTo({ ...homeView(), duration }); + }, + + layers() { + return [...layers.values()].map((layer) => layer.info()); + }, + + setLayer(id, on) { + const layer = layers.get(id); + if (!layer) throw new RangeError(`Unknown layer: ${id}`); + if (!layer.available) return false; + layer.setOn(on); + if (on) { + if (lastView) layer.onView(lastView); + layer.onTime(timeMs(), live); + } else if (selected?.layer === id) api.select(null); + return true; + }, + + setFamily(family, on) { + for (const layer of layers.values()) + if (layer.family === family && layer.available) + api.setLayer(layer.id, on); + }, + + setTime(date) { + if (date == null) { + live = true; + clock.clockStep = Cesium.ClockStep.SYSTEM_CLOCK; + clock.shouldAnimate = true; + clock.currentTime = Cesium.JulianDate.now(); + applyTime(); + emit('time', { live: true, time: new Date() }); + return; + } + const ms = date instanceof Date ? date.getTime() : Number(date); + if (!Number.isFinite(ms)) + throw new TypeError('setTime needs a Date, epoch ms or null'); + live = false; + clock.shouldAnimate = false; + clock.clockStep = Cesium.ClockStep.TICK_DEPENDENT; + clock.currentTime = Cesium.JulianDate.fromDate(new Date(ms)); + // Coalesce a fast scrub into ~20 layer updates a second. + if (!timeTimer) timeTimer = setTimeout(applyTime, 50); + requestRender(); + emit('time', { live: false, time: new Date(ms) }); + }, + + time() { + return { live, time: new Date(timeMs()) }; + }, + + timeWindow() { + const now = Date.now(); + return { + start: new Date(now - 72 * 3600_000), + end: new Date(now + 120 * 3600_000), + now: new Date(now), + }; + }, + + setLens(id) { + post.setLens(id); + imagery.setNightMode(id === 'night' || id === 'nvg'); + emit('lens', { id }); + }, + + lens() { + return post.lens; + }, + + async focus(situation, { solo = false } = {}) { + if (!situation) return; + if (solo) + for (const layer of layers.values()) + if (layer.on && !situation.layers?.includes(layer.id)) + layer.setOn(false); + for (const id of situation.layers || []) + if (layers.has(id)) api.setLayer(id, true); + if (Number.isFinite(situation.time?.at)) + api.setTime(new Date(situation.time.at)); + else if (!live) api.setTime(null); + const cam = situation.camera || {}; + await flyTo({ + lon: cam.lon ?? situation.lon, + lat: cam.lat ?? situation.lat, + height: cam.height ?? 200_000, + heading: cam.heading ?? 0, + pitch: cam.pitch ?? -60, + duration: cam.duration, + targetHeight: cam.targetHeight, + }); + }, + + select(id) { + const target = resolveSelectable(id); + if (!target) { + selected = null; + drawSelection(); + requestRender(); + return null; + } + const pick = layers.get(target.layer).pick(target.key); + if (!pick) { + selected = null; + drawSelection(); + return null; + } + selected = { ...target, pick }; + drawSelection(); + requestRender(); + return pick; + }, + + on(event, fn) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event).add(fn); + return () => api.off(event, fn); + }, + + off(event, fn) { + listeners.get(event)?.delete(fn); + }, + + pause() { + if (!active) return; + active = false; + viewer.useDefaultRenderLoop = false; + }, + + resume() { + if (active) return; + active = true; + viewer.useDefaultRenderLoop = true; + if (live) clock.currentTime = Cesium.JulianDate.now(); + for (const layer of layers.values()) if (layer.on) layer.kick(); + scheduleView(); + requestRender(); + }, + + contactsNear(lon, lat, km = 50) { + return contactsNearImpl(lon, lat, km, { timeMs: timeMs() }).map( + ({ + kind, + id, + title, + lon: lo, + lat: la, + altM, + headingDeg, + speedMps, + distanceKm, + }) => ({ + kind, + id, + title, + lon: lo, + lat: la, + altM, + headingDeg, + speedMps, + distanceKm, + }), + ); + }, + + situations(extra = {}) { + return buildSituations( + { feeds, timeMs }, + { observer: extra.observer ?? opts.observer }, + ); + }, + + credits() { + const list = imagery.credits(); + for (const layer of layers.values()) + if (layer.on && DATA_CREDITS[layer.id]) + list.push({ + id: `data-${layer.id}`, + text: DATA_CREDITS[layer.id], + html: DATA_CREDITS[layer.id], + }); + return list; + }, + + /** Screen position (CSS px) of a point, or null when behind the globe. */ + project(lon, lat, altM = 0) { + const position = Cesium.Cartesian3.fromDegrees(lon, lat, altM); + const occluder = new Cesium.EllipsoidalOccluder( + scene.globe.ellipsoid, + camera.positionWC, + ); + if (!occluder.isPointVisible(position)) return null; + const p = scene.cartesianToCanvasCoordinates(position); + return p ? { x: p.x, y: p.y } : null; + }, + + /** + * Up to `max` contacts worth an HTML label right now (DESIGN.md §5: no + * Cesium labels, ≤ 40 visible): on-screen glyphs, selected first, then + * by layer priority and distance from the screen centre. + */ + labels(max = 40) { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + const heightM = camera.positionCartographic.height; + const planetary = heightM > 3_000_000; + const occluder = new Cesium.EllipsoidalOccluder( + scene.globe.ellipsoid, + camera.positionWC, + ); + const scratch = new Cesium.Cartesian2(); + const candidates = []; + for (const layer of layers.values()) { + const glyphs = + layer.on && layer.renderer?.glyphMode && layer.renderer.glyphs; + if (!glyphs) continue; + // Semantic zoom for words too: from orbit only the headline layers. + if (planetary && !PLANETARY_LABELS.has(layer.id)) continue; + const rank = LABEL_PRIORITY.indexOf(layer.id); + for (let i = 0; i < glyphs.length; i++) { + const bb = glyphs.get(i); + if (!occluder.isPointVisible(bb.position)) continue; + const p = scene.cartesianToCanvasCoordinates(bb.position, scratch); + if (!p || p.x < 0 || p.y < 0 || p.x > w || p.y > h) continue; + const isSelected = + selected?.layer === layer.id && selected?.key === bb.id.key; + const d = Math.hypot(p.x - w / 2, p.y - h / 2) / Math.max(w, h); + candidates.push({ + layer, + key: bb.id.key, + x: p.x, + y: p.y, + score: (isSelected ? -100 : 0) + (rank < 0 ? 20 : rank) + d * 4, + }); + } + } + candidates.sort((a, b) => a.score - b.score); + // Greedy placement: a label never overlaps one already placed. + const placed = []; + const out = []; + for (const c of candidates) { + if (out.length >= max) break; + const pick = c.layer.pick(c.key); + if (!pick) continue; + if ( + planetary && + pick.kind === 'earthquake' && + !(pick.meta?.magnitude >= 5.5) + ) + continue; + const box = { + x0: c.x + 6, + x1: c.x + 20 + pick.title.length * 6.2, + y0: c.y - 10, + y1: c.y + 10, + }; + if ( + placed.some( + (b) => + box.x0 < b.x1 && box.x1 > b.x0 && box.y0 < b.y1 && box.y1 > b.y0, + ) + ) + continue; + placed.push(box); + out.push({ + id: pick.id, + layer: c.layer.id, + kind: pick.kind, + title: pick.title, + x: c.x, + y: c.y, + }); + } + return out; + }, + + quality() { + return governor.state(); + }, + + setQuality(q) { + governor.setQuality(q); + }, + + requestRender, + + get viewer() { + return viewer; + }, + + destroy() { + clearInterval(tick); + clearTimeout(viewTimer); + clearTimeout(layersTimer); + clearTimeout(timeTimer); + resizeObserver.disconnect(); + document.removeEventListener('visibilitychange', onVisibility); + removeCameraChanged(); + removeMoveEnd(); + handler.destroy(); + for (const layer of layers.values()) layer.destroy(); + governor.destroy(); + post.destroy(); + viewer.destroy(); + host.remove(); + listeners.clear(); + }, + }; + + // ── Boot ───────────────────────────────────────────────────────── + const home = homeView(); + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(home.lon, home.lat, home.height), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 }, + }); + api.setLens(opts.lens); + lastView = computeView(); + for (const id of opts.layers ?? DEFAULT_LAYERS) + if (layers.has(id)) api.setLayer(id, true); + refreshLayerViews(); + requestRender(); + // Listeners attach after this resolves: hand them the starting state. + setTimeout(() => { + emit('quality', governor.state()); + emit('layers', api.layers()); + }, 0); + return api; +} diff --git a/mobile/globe/layers/air.js b/mobile/globe/layers/air.js new file mode 100644 index 000000000..b516d1126 --- /dev/null +++ b/mobile/globe/layers/air.js @@ -0,0 +1,287 @@ +/** + * Sky family: commercial flights, military aircraft, helicopters, private/GA + * and emergency squawks. Every layer reads the shared flight and military + * feeds; derived layers (helicopters, GA, squawks) are filters, never extra + * downloads. + */ +import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js'; +import { haversineKm } from '../geo.js'; + +const SKY = FAMILY_COLORS.Sky; +const M_TO_FT = 3.28084; +const MPS_TO_KT = 1 / 0.514444; +const HISTORY = 24; + +export class AircraftLayer extends Layer { + /** + * @param {object} ctx + * @param {object} def layer definition + * @param {{sources:{feed:object, filter:(r:object)=>boolean}[], tint:string, + * lodHeightM?:number, cap?:number, alwaysGlyph?:boolean, kind?:string}} opts + */ + constructor(ctx, def, opts) { + super(ctx, { pollMs: 12_000, ...def }); + this.sources = opts.sources; + this.kind = opts.kind || 'aircraft'; + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(opts.tint || SKY), + glyphName: 'plane', + lodHeightM: opts.lodHeightM ?? 1_800_000, + cap: opts.cap ?? 700, + deadReckon: true, + dotScale: opts.dotScale ?? 0.36, + alwaysGlyph: opts.alwaysGlyph, + glyphScale: opts.glyphScale ?? 0.5, + }); + this.records = new Map(); + this.history = new Map(); + this.groundTint = color(opts.tint || SKY).withAlpha(0.55); + } + + async refresh() { + const results = await Promise.allSettled( + this.sources.map((s) => s.feed.get({ maxAgeMs: 9_000 })), + ); + const flightData = this.sources[0].feed.data; + this._regionalNote = flightData?.regional + ? 'OpenSky unavailable — adsb.lol snapshot within 250 nm of the view' + : ''; + const records = []; + let anyOk = false; + this.sources.forEach((s, i) => { + this.fromFeed(s.feed); + if (results[i].status !== 'fulfilled') return; + anyOk = true; + for (const r of results[i].value.records) + if (s.filter(r)) records.push(r); + }); + if (!anyOk) throw results[0].reason; + if (this._regionalNote && !this.message) this.message = this._regionalNote; + this.ingest(records); + } + + ingest(records) { + const next = new Map(); + for (const r of records) { + next.set(r.id, r); + let h = this.history.get(r.id); + if (!h) this.history.set(r.id, (h = [])); + const last = h.at(-1); + if (!last || last[0] !== r.lon || last[1] !== r.lat) { + h.push([r.lon, r.lat, r.altM]); + if (h.length > HISTORY) h.shift(); + } + } + for (const id of this.history.keys()) + if (!next.has(id)) this.history.delete(id); + this.records = next; + this.count = next.size; + this.renderer.setItems( + records.map((r) => ({ + key: r.id, + lon: r.lon, + lat: r.lat, + altM: r.altM, + heading: r.heading, + speed: r.speed, + onGround: r.onGround, + seenAt: r.seenAt || Date.now(), + glyph: r.helicopter ? 'heli' : 'plane', + // Parked traffic recedes so the apron does not bloom into a blob. + color: r.emergency + ? color('#FF3B30') + : r.onGround + ? this.groundTint + : undefined, + glyphScale: r.onGround + ? 0.6 + : r.helicopter + ? 0.95 + : r.cls === 'widebody' + ? 1.15 + : 1, + })), + ); + } + + onView(view) { + this.renderer.setView(view); + // A regional fallback snapshot follows the camera: refetch once the + // view has moved well away from the snapshot's anchor. + const data = this.sources[0].feed.data; + if ( + this.on && + data?.regional && + data.anchor && + view.center && + haversineKm( + data.anchor.lat, + data.anchor.lon, + view.center.lat, + view.center.lon, + ) > 300 && + Date.now() - (this._regionalKick || 0) > 8_000 + ) { + this._regionalKick = Date.now(); + this.kick(); + } + } + + onTime(ms, live) { + // Aircraft are live-only: away from now they dim to show they are not + // a reconstruction of that moment. + this.renderer.setAlpha(live ? 1 : 0.3); + } + + onTick(nowMs, live) { + return live && this.renderer.tick(nowMs); + } + + locate(key) { + const r = this.records.get(key); + if (!r) return null; + const it = this.renderer.byKey.get(key); + const [lon, lat] = it ? this.renderer.where(it) : [r.lon, r.lat]; + return [lon, lat, r.altM]; + } + + trail(key) { + const h = this.history.get(key) || []; + const now = this.locate(key); + return now ? [...h, now] : h; + } + + pick(key) { + const r = this.records.get(key); + if (!r) return null; + const [lon, lat, altM] = this.locate(key); + const alt = r.onGround + ? 'On ground' + : `${Math.round((r.altM * M_TO_FT) / 100) * 100} ft`; + const spd = Number.isFinite(r.speed) + ? `${Math.round(r.speed * MPS_TO_KT)} kt` + : null; + return { + kind: this.kind, + id: r.id, + title: r.callsign, + subtitle: [ + r.military ? r.type || 'Military' : r.country, + alt, + spd, + r.emergency ? `SQUAWK ${r.squawk}` : null, + ] + .filter(Boolean) + .join(' · '), + lon, + lat, + meta: { + layer: this.id, + icao24: r.id, + altitudeM: r.altM, + speedMps: r.speed, + headingDeg: r.heading, + verticalRateMps: r.vRate ?? null, + squawk: r.squawk, + type: r.type || null, + class: r.cls, + country: r.country || null, + onGround: r.onGround, + source: this.source, + }, + }; + } + + contacts() { + const out = []; + for (const r of this.records.values()) { + const it = this.renderer.byKey.get(r.id); + const [lon, lat] = it ? this.renderer.where(it) : [r.lon, r.lat]; + out.push({ + kind: this.kind, + id: r.id, + title: r.callsign, + lon, + lat, + altM: r.altM, + headingDeg: r.heading, + speedMps: r.speed, + layer: this.id, + }); + } + return out; + } + + onHide() {} +} + +/** Build the Sky family. */ +export function createSkyLayers(ctx, { flights, military }) { + const all = () => true; + return [ + new AircraftLayer( + ctx, + { id: 'flights', family: 'Sky', name: 'Commercial flights' }, + { + sources: [{ feed: flights, filter: (r) => !r.ga && !r.helicopter }], + tint: SKY, + }, + ), + new AircraftLayer( + ctx, + { id: 'military', family: 'Sky', name: 'Military aircraft' }, + { + sources: [{ feed: military, filter: all }], + tint: '#E3F1FF', + kind: 'military', + lodHeightM: 6_000_000, + alwaysGlyph: true, + glyphScale: 0.62, + dotScale: 0.6, + }, + ), + new AircraftLayer( + ctx, + { id: 'helicopters', family: 'Sky', name: 'Helicopters' }, + { + sources: [ + { feed: flights, filter: (r) => r.helicopter }, + { feed: military, filter: (r) => r.helicopter }, + ], + tint: '#A8E0FF', + kind: 'helicopter', + alwaysGlyph: true, + glyphScale: 0.6, + }, + ), + new AircraftLayer( + ctx, + { id: 'ga', family: 'Sky', name: 'Private / GA' }, + { + sources: [{ feed: flights, filter: (r) => r.ga && !r.helicopter }], + tint: '#B9D9F5', + dotScale: 0.3, + glyphScale: 0.42, + }, + ), + new AircraftLayer( + ctx, + { id: 'squawk', family: 'Sky', name: 'Emergency squawks' }, + { + sources: [ + { feed: flights, filter: (r) => r.emergency }, + { + feed: military, + filter: (r) => ['7500', '7600', '7700'].includes(r.squawk), + }, + ], + tint: '#FF3B30', + kind: 'emergency', + alwaysGlyph: true, + glyphScale: 0.8, + dotScale: 0.9, + }, + ), + ]; +} diff --git a/mobile/globe/layers/base.js b/mobile/globe/layers/base.js new file mode 100644 index 000000000..16b8afe67 --- /dev/null +++ b/mobile/globe/layers/base.js @@ -0,0 +1,384 @@ +/** + * Layer plumbing: lifecycle/polling/status for every live layer, and the + * ContactRenderer that draws point contacts with semantic zoom. + * + * Semantic zoom: every contact is a soft glow dot (one BillboardCollection, + * one draw call) so the planet reads as density at orbital altitude. Below the + * layer's LOD height, the contacts nearest the view centre (capped) also get a + * heading-aligned glyph that dead-reckons between polls. + */ +import * as Cesium from 'cesium'; +import { glyph } from '../glyphs.js'; +import { offsetLonLat } from '../geo.js'; + +export const FAMILY_COLORS = Object.freeze({ + Sky: '#7CC4FF', + Sea: '#38E1C6', + Orbit: '#C9A7FF', + Hazards: '#FF6A3D', + Eyes: '#FFD166', + Ground: '#9BE15D', + Weather: '#8AB4F8', + Infra: '#B8C0CC', +}); + +export const color = (css, alpha = 1) => + Cesium.Color.fromCssColorString(css).withAlpha(alpha); + +export class Layer { + /** + * @param {object} ctx engine context from index.js + * @param {{id:string,family:string,name:string,pollMs?:number}} def + */ + constructor(ctx, def) { + this.ctx = ctx; + this.id = def.id; + this.family = def.family; + this.name = def.name; + this.pollMs = def.pollMs ?? 0; + this.available = true; + this.on = false; + this.count = 0; + this.status = 'off'; + this.message = ''; + this.source = def.source || ''; + this.updatedAt = null; + this.loading = false; + this.root = ctx.scene.primitives.add(new Cesium.PrimitiveCollection()); + this.root.show = false; + this._timer = null; + this._gen = 0; + } + + info() { + return { + id: this.id, + family: this.family, + name: this.name, + on: this.on, + count: this.on ? this.count : 0, + status: this.on ? this.status : 'off', + available: this.available, + loading: this.on && this.loading, + message: this.message, + source: this.source, + updatedAt: this.updatedAt, + }; + } + + setOn(on) { + on = Boolean(on); + if (on === this.on) return; + this.on = on; + this.root.show = on; + this._gen++; + clearTimeout(this._timer); + this._timer = null; + if (on) { + this.status = this.updatedAt ? this.status : 'stale'; + this.kick(); + } else { + this.onHide?.(); + } + this.ctx.layersChanged(); + this.ctx.requestRender(); + } + + /** Refresh now (if on and active) and keep polling. */ + kick() { + if (!this.on) return; + clearTimeout(this._timer); + this._timer = null; + const gen = this._gen; + if (!this.ctx.active()) return; // resume() kicks again + this.loading = true; + Promise.resolve() + .then(() => this.refresh()) + .then( + () => { + if (gen !== this._gen) return; + this.updatedAt = Date.now(); + }, + (error) => { + if (gen !== this._gen) return; + this.status = 'error'; + this.message = error?.message || String(error); + console.warn(`[globe:${this.id}]`, this.message); + }, + ) + .finally(() => { + if (gen !== this._gen) return; + this.loading = false; + this.ctx.layersChanged(); + this.ctx.requestRender(); + if (this.pollMs && this.on) + this._timer = setTimeout(() => this.kick(), this.pollMs); + }); + } + + /** Mark status from a feed after a refresh. */ + fromFeed(feed) { + if (feed.error && !feed.data) { + this.status = 'error'; + this.message = feed.error.message; + } else { + this.status = feed.stale ? 'stale' : 'live'; + this.message = feed.error ? `Refresh failed: ${feed.error.message}` : ''; + } + if (feed.data?.source) this.source = feed.data.source; + } + + // Overridables + async refresh() {} + onView() {} + onTime() {} + onTick() {} + pick() { + return null; + } + contacts() { + return []; + } + destroy() { + clearTimeout(this._timer); + this.ctx.scene.primitives.remove(this.root); + } +} + +/** A catalog entry the prototype does not render yet. */ +export class UnavailableLayer { + constructor(def) { + Object.assign(this, def); + this.on = false; + this.available = false; + } + info() { + return { + id: this.id, + family: this.family, + name: this.name, + on: false, + count: 0, + status: 'off', + available: false, + loading: false, + message: this.message || 'Not in the prototype yet', + source: '', + updatedAt: null, + }; + } + setOn() {} + kick() {} + onView() {} + onTime() {} + onTick() {} + pick() { + return null; + } + contacts() { + return []; + } + destroy() {} +} + +const DOT = () => glyph('dot'); +const GLYPH_NEAR_FAR = new Cesium.NearFarScalar(2e4, 1.25, 3e6, 0.7); +const GLYPH_NO_DEPTH_WITHIN_M = 150_000; + +/** + * Point contacts with semantic zoom. + * items: [{ key, lon, lat, altM?, heading?, speed?, seenAt?, color?, glyph?, + * dotScale?, glyphScale? }] + */ +export class ContactRenderer { + constructor( + root, + { + tint, + glyphName = 'dot', + lodHeightM = 2_500_000, + cap = 900, + rotate = true, + dotScale = 0.42, + glyphScale = 0.5, + layerId, + deadReckon = false, + dotAlpha = 0.85, + alwaysGlyph = false, + }, + ) { + this.layerId = layerId; + this.tint = tint; + this.glyphName = glyphName; + this.lodHeightM = lodHeightM; + this.cap = cap; + this.rotate = rotate; + this.dotScale = dotScale; + this.glyphScale = glyphScale; + this.deadReckon = deadReckon; + this.dotAlpha = dotAlpha; + this.alwaysGlyph = alwaysGlyph; + this.dots = root.add( + new Cesium.BillboardCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }), + ); + this.glyphs = root.add(new Cesium.BillboardCollection()); + this.items = []; + this.byKey = new Map(); + this.view = null; + this.glyphMode = false; + this.alpha = 1; + this._scratch = new Cesium.Cartesian3(); + } + + setItems(items) { + this.items = items; + this.byKey = new Map(items.map((it) => [it.key, it])); + const dot = DOT(); + this.dots.removeAll(); + const near = new Cesium.NearFarScalar(4e5, 1.0, 2.2e7, 0.45); + for (const it of items) { + const c = it.color || this.tint; + this.dots.add({ + position: Cesium.Cartesian3.fromDegrees(it.lon, it.lat, it.altM || 0), + image: dot.image, + imageId: dot.imageId, + color: c.withAlpha(c.alpha * this.dotAlpha * this.alpha), + scale: (it.dotScale ?? 1) * this.dotScale, + scaleByDistance: near, + id: { layer: this.layerId, key: it.key }, + }); + } + this._rebuildGlyphs(); + } + + setAlpha(alpha) { + if (alpha === this.alpha) return; + this.alpha = alpha; + this.setItems(this.items); + } + + setView(view) { + this.view = view; + this._rebuildGlyphs(); + } + + _rebuildGlyphs() { + const view = this.view; + this.glyphs.removeAll(); + this.glyphMode = + this.alwaysGlyph || Boolean(view && view.heightM < this.lodHeightM); + if (!this.glyphMode || !view) return; + const { rect } = view; + const cLat = view.center?.lat ?? 0; + const cLon = view.center?.lon ?? 0; + const inView = []; + for (const it of this.items) { + if (rect && !inRect(rect, it.lon, it.lat)) continue; + const dLat = it.lat - cLat; + const dLon = (it.lon - cLon) * Math.cos((cLat * Math.PI) / 180); + inView.push([dLat * dLat + dLon * dLon, it]); + } + inView.sort((a, b) => a[0] - b[0]); + const n = Math.min(this.cap, inView.length); + for (let i = 0; i < n; i++) { + const it = inView[i][1]; + const g = glyph(it.glyph || this.glyphName); + it._bb = this.glyphs.add({ + position: Cesium.Cartesian3.fromDegrees(it.lon, it.lat, it.altM || 0), + image: g.image, + imageId: g.imageId, + color: (it.color || this.tint).withAlpha( + (it.color || this.tint).alpha * this.alpha, + ), + scale: (it.glyphScale ?? 1) * this.glyphScale, + rotation: this.rotate ? -Cesium.Math.toRadians(it.heading || 0) : 0, + alignedAxis: this.rotate + ? Cesium.Cartesian3.UNIT_Z + : Cesium.Cartesian3.ZERO, + id: { layer: this.layerId, key: it.key }, + scaleByDistance: GLYPH_NEAR_FAR, + // Contacts report ellipsoid/MSL heights; over terrain a grounded or + // low contact would sink below the surface. Close in, glyphs ignore + // depth (a God's-eye HUD), far out the globe still occludes them. + disableDepthTestDistance: GLYPH_NO_DEPTH_WITHIN_M, + }); + } + } + + /** + * Move items in place (same keys, same order as the last setItems): no + * billboard churn. `from`/`to` bound a round-robin slice of the dots; + * glyph billboards always move. + */ + moveItems(from = 0, to = this.items.length) { + const n = Math.min(to, this.items.length, this.dots.length); + for (let i = from; i < n; i++) { + const it = this.items[i]; + this.dots.get(i).position = Cesium.Cartesian3.fromDegrees( + it.lon, + it.lat, + it.altM || 0, + ); + } + for (let i = 0; i < this.glyphs.length; i++) { + const bb = this.glyphs.get(i); + const it = this.byKey.get(bb.id.key); + if (it) + bb.position = Cesium.Cartesian3.fromDegrees( + it.lon, + it.lat, + it.altM || 0, + ); + } + } + + /** Dead-reckon glyphs from their last fix. Returns true when anything moved. */ + tick(nowMs) { + if (!this.deadReckon || !this.glyphMode) return false; + let moved = false; + for (let i = 0; i < this.glyphs.length; i++) { + const bb = this.glyphs.get(i); + const it = this.byKey.get(bb.id.key); + if (!it || !it.speed || it.onGround || !it.seenAt) continue; + const dt = Math.min(90, Math.max(0, (nowMs - it.seenAt) / 1000)); + const [lon, lat] = offsetLonLat( + it.lon, + it.lat, + it.heading, + it.speed * dt, + ); + bb.position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + it.altM || 0, + undefined, + this._scratch, + ); + moved = true; + } + return moved; + } + + /** Current (dead-reckoned) lon/lat of an item. */ + where(it, nowMs = Date.now()) { + if (!this.deadReckon || !it.speed || it.onGround || !it.seenAt) + return [it.lon, it.lat]; + const dt = Math.min(90, Math.max(0, (nowMs - it.seenAt) / 1000)); + return offsetLonLat(it.lon, it.lat, it.heading, it.speed * dt); + } + + clear() { + this.items = []; + this.byKey.clear(); + this.dots.removeAll(); + this.glyphs.removeAll(); + } +} + +export function inRect(rect, lon, lat) { + if (lat < rect.south || lat > rect.north) return false; + if (rect.west <= rect.east) return lon >= rect.west && lon <= rect.east; + return lon >= rect.west || lon <= rect.east; // crosses the antimeridian +} diff --git a/mobile/globe/layers/catalog.js b/mobile/globe/layers/catalog.js new file mode 100644 index 000000000..615e2bfb0 --- /dev/null +++ b/mobile/globe/layers/catalog.js @@ -0,0 +1,95 @@ +/** + * The full layer catalog (DESIGN.md §8) in display order. Entries the + * prototype renders are built by the family factories; the rest are listed + * honestly as unavailable so the lens dock can show the whole catalog. + */ +import { UnavailableLayer } from './base.js'; +import { createSkyLayers } from './air.js'; +import { createOrbitLayers } from './orbit.js'; +import { createHazardLayers } from './hazards.js'; +import { createSeaLayers, createEyesLayers } from './sea.js'; + +export const FAMILIES = Object.freeze([ + 'Sky', + 'Sea', + 'Orbit', + 'Hazards', + 'Eyes', + 'Ground', + 'Weather', + 'Infra', +]); + +export const CATALOG = Object.freeze([ + ['Sky', 'flights', 'Commercial flights'], + ['Sky', 'military', 'Military aircraft'], + ['Sky', 'helicopters', 'Helicopters'], + ['Sky', 'ga', 'Private / GA'], + ['Sky', 'squawk', 'Emergency squawks'], + ['Sky', 'drones', 'Drones (Remote ID)'], + ['Sky', 'airport-boards', 'Airport boards'], + ['Sky', 'holding', 'Holding patterns'], + ['Sea', 'vessels', 'AIS vessels'], + ['Sea', 'ports', 'Ports'], + ['Sea', 'tankers', 'Tankers / cargo'], + ['Sea', 'fishing', 'Fishing effort'], + ['Sea', 'dark-vessels', 'Dark vessels (AIS gaps)'], + ['Sea', 'cables', 'Submarine cables'], + ['Orbit', 'satellites', 'Satellites (TLE)'], + ['Orbit', 'iss', 'ISS & crewed'], + ['Orbit', 'starlink', 'Starlink trains'], + ['Orbit', 'launches', 'Launches & trajectories'], + ['Orbit', 'debris', 'Debris'], + ['Orbit', 'passes', 'Ground tracks & passes'], + ['Hazards', 'earthquakes', 'Earthquakes'], + ['Hazards', 'fires', 'Active fires (FIRMS)'], + ['Hazards', 'fire-perimeters', 'Fire perimeters'], + ['Hazards', 'floods', 'Floods'], + ['Hazards', 'cyclones', 'Tropical cyclones'], + ['Hazards', 'volcanoes', 'Volcanoes'], + ['Hazards', 'air-quality', 'Air quality'], + ['Eyes', 'cctv', 'Public CCTV'], + ['Eyes', 'captures', 'Signed captures'], + ['Eyes', 'street-level', 'Street-level imagery'], + ['Eyes', 'holograms', 'Holograms'], + ['Eyes', 'streams', 'Live streams'], + ['Ground', 'transit', 'Transit vehicles'], + ['Ground', 'traffic', 'Traffic flow'], + ['Ground', 'bikeshare', 'Bike share'], + ['Ground', 'rail', 'Rail'], + ['Ground', 'venues', 'Events & venues'], + ['Ground', 'alpr', 'ALPR cameras'], + ['Weather', 'radar', 'Radar'], + ['Weather', 'clouds', 'Clouds'], + ['Weather', 'lightning', 'Lightning'], + ['Weather', 'wind', 'Wind'], + ['Weather', 'forecast', 'Forecast models'], + ['Infra', 'datacenters', 'Datacenters'], + ['Infra', 'dams', 'Dams'], + ['Infra', 'power', 'Power plants'], + ['Infra', 'installations', 'Military installations'], + ['Infra', 'gnss', 'GNSS interference'], + ['Infra', 'recent-imagery', 'Recent satellite imagery'], +]); + +/** Build every catalog layer; returns a Map id → layer in catalog order. */ +export function buildLayers(ctx, feeds) { + const live = new Map(); + for (const layer of [ + ...createSkyLayers(ctx, feeds), + ...createSeaLayers(ctx, feeds), + ...createOrbitLayers(ctx, feeds), + ...createHazardLayers(ctx, feeds), + ...createEyesLayers(ctx, feeds), + ]) + live.set(layer.id, layer); + const out = new Map(); + for (const [family, id, name] of CATALOG) { + const layer = live.get(id); + if (layer) { + layer.family = family; + out.set(id, layer); + } else out.set(id, new UnavailableLayer({ id, family, name })); + } + return out; +} diff --git a/mobile/globe/layers/hazards.js b/mobile/globe/layers/hazards.js new file mode 100644 index 000000000..782550496 --- /dev/null +++ b/mobile/globe/layers/hazards.js @@ -0,0 +1,622 @@ +/** + * Hazards family: earthquakes (USGS), active fires (NASA FIRMS, or NASA EONET + * events when no FIRMS key), tropical cyclones (NOAA NHC + EONET) with + * forecast tracks the time ribbon can scrub into, volcanoes (EONET) and the + * bundled Bhote Koshi flood corridor. + */ +import * as Cesium from 'cesium'; +import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js'; +import { relTime } from '../geo.js'; + +const HAZ = FAMILY_COLORS.Hazards; +const WEEK = 7 * 86_400_000; + +/** A point-event layer filtered to what had happened by the clock time. */ +class EventLayer extends Layer { + constructor(ctx, def, opts) { + super(ctx, def); + this.feed = opts.feed; + this.select = opts.select; // data → records [{id, lon, lat, t, ...}] + this.style = opts.style; // (record, ageMs) → item fields + this.describe = opts.describe; // (record, clockMs) → {title, subtitle, meta} + this.windowMs = opts.windowMs ?? WEEK; + this.kind = opts.kind; + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(opts.tint || HAZ), + glyphName: opts.glyph, + rotate: false, + alwaysGlyph: opts.alwaysGlyph, + lodHeightM: opts.lodHeightM ?? 3_000_000, + cap: opts.cap ?? 400, + dotScale: opts.dotScale ?? 0.5, + glyphScale: opts.glyphScale ?? 0.5, + }); + this.records = new Map(); + } + + async refresh() { + try { + await this.feed.get(); + } finally { + this.fromFeed(this.feed); + } + this._draw(); + } + + _draw() { + const data = this.feed.data; + if (!data) return; + const t = this.ctx.timeMs(); + const items = []; + this.records = new Map(); + for (const r of this.select(data)) { + const age = Number.isFinite(r.t) ? t - r.t : 0; + if (age < 0 || age > this.windowMs) continue; + this.records.set(r.id, r); + items.push({ + key: r.id, + lon: r.lon, + lat: r.lat, + altM: 0, + ...this.style(r, age), + }); + } + this.count = items.length; + this.renderer.setItems(items); + } + + onView(view) { + this.renderer.setView(view); + } + onTime() { + if (this.on) this._draw(); + } + locate(key) { + const r = this.records.get(key); + return r ? [r.lon, r.lat, 0] : null; + } + pick(key) { + const r = this.records.get(key); + if (!r) return null; + const d = this.describe(r, this.ctx.timeMs()); + return { + kind: this.kind, + id: r.id, + lon: r.lon, + lat: r.lat, + ...d, + meta: { layer: this.id, source: this.source, ...d.meta }, + }; + } +} + +function quakeColor(mag) { + if (mag >= 6) return '#FF3B30'; + if (mag >= 5) return '#FF6A3D'; + if (mag >= 4) return '#FFA23D'; + return '#FFD08A'; +} + +export function createHazardLayers(ctx, feeds) { + const quakes = new EventLayer( + ctx, + { + id: 'earthquakes', + family: 'Hazards', + name: 'Earthquakes', + pollMs: 5 * 60_000, + }, + { + feed: feeds.quakes, + kind: 'earthquake', + glyph: 'quake', + alwaysGlyph: true, + cap: 600, + select: (d) => + d.records.map((q) => ({ ...q, id: q.stableId, t: q.time })), + style: (q, age) => { + const fresh = age < 86_400_000; + return { + color: color(quakeColor(q.mag), fresh ? 1 : 0.55), + glyphScale: 0.32 + Math.max(0, q.mag - 2.5) * 0.24, + dotScale: 0.6 + Math.max(0, q.mag - 2.5) * 0.4, + }; + }, + describe: (q, t) => ({ + title: `M${q.mag.toFixed(1)} earthquake`, + subtitle: `${q.place || 'Unknown location'} · ${relTime(q.time, t)}`, + meta: { + magnitude: q.mag, + depthKm: q.depthKm, + time: q.time ? new Date(q.time).toISOString() : null, + usgsId: q.usgsId, + url: q.usgsId + ? `https://earthquake.usgs.gov/earthquakes/eventpage/${q.usgsId}` + : null, + }, + }), + }, + ); + + const fires = new EventLayer( + ctx, + { + id: 'fires', + family: 'Hazards', + name: 'Active fires (FIRMS)', + pollMs: 10 * 60_000, + }, + { + feed: feeds.fires, + kind: 'fire', + glyph: 'fire', + windowMs: 3 * 86_400_000, + lodHeightM: 1_500_000, + cap: 500, + select: (d) => { + // EONET events keep their last report date; a 3-day window would + // hide long-running fires, so events show while open. + if (d.mode === 'events') + return d.records.map((r) => ({ ...r, t: null })); + return d.records; + }, + style: (f) => { + const big = (f.frp ?? 0) > 50 || (f.acres ?? 0) > 5000; + return { + color: color(big ? '#FF5A1F' : '#FF9A3D', 0.95), + glyphScale: big ? 0.75 : 0.55, + dotScale: big ? 0.8 : 0.5, + }; + }, + describe: (f, t) => ({ + title: f.title || 'Fire hotspot', + subtitle: [ + f.acres ? `${Math.round(f.acres).toLocaleString()} acres` : null, + f.frp ? `${f.frp.toFixed(0)} MW FRP` : null, + Number.isFinite(f.t) ? relTime(f.t, t) : 'open event', + ] + .filter(Boolean) + .join(' · '), + meta: { + frpMw: f.frp ?? null, + acres: f.acres ?? null, + url: f.link || null, + }, + }), + }, + ); + fires.refresh = async function () { + await EventLayer.prototype.refresh.call(this); + const d = this.feed.data; + this.name = 'Active fires (FIRMS)'; + if (d?.mode === 'events') { + this.message = d.firmsKeyRequired + ? 'FIRMS key not set on server — showing NASA EONET wildfire events' + : 'FIRMS unavailable — showing NASA EONET wildfire events'; + } + }; + + const volcanoes = new EventLayer( + ctx, + { + id: 'volcanoes', + family: 'Hazards', + name: 'Volcanoes', + pollMs: 30 * 60_000, + }, + { + feed: feeds.eonet, + kind: 'volcano', + glyph: 'volcano', + alwaysGlyph: true, + windowMs: Infinity, + select: (d) => d.volcanoes.map((v) => ({ ...v, t: null })), + style: () => ({ color: color('#FF7A45'), glyphScale: 0.6 }), + describe: (v) => ({ + title: v.title, + subtitle: `Active · since ${new Date(v.firstT).toISOString().slice(0, 10)}`, + meta: { url: v.link }, + }), + }, + ); + + return [ + quakes, + fires, + new CycloneLayer(ctx, { feed: feeds.cyclones }), + new FloodLayer(ctx, { feed: feeds.flood }), + volcanoes, + ]; +} + +/* ───────────────────────── Cyclones ───────────────────────── */ + +/** Position of a storm at a clock time: interpolated along the forecast. */ +export function stormPositionAt(storm, ms) { + const f = storm.forecast; + if (!f?.length) + return { lon: storm.lon, lat: storm.lat, windKt: storm.windKt }; + const tau = (ms - storm.t) / 3_600_000; + if (tau <= 0) return { lon: storm.lon, lat: storm.lat, windKt: storm.windKt }; + for (let i = 1; i < f.length; i++) { + if (tau <= f[i].tauHours) { + const a = f[i - 1]; + const b = f[i]; + const k = (tau - a.tauHours) / Math.max(1e-6, b.tauHours - a.tauHours); + return { + lon: a.lon + (b.lon - a.lon) * k, + lat: a.lat + (b.lat - a.lat) * k, + windKt: a.windKt + (b.windKt - a.windKt) * k, + forecast: true, + }; + } + } + return null; // beyond the forecast +} + +class CycloneLayer extends Layer { + constructor(ctx, { feed }) { + super(ctx, { + id: 'cyclones', + family: 'Hazards', + name: 'Tropical cyclones', + pollMs: 15 * 60_000, + }); + this.feed = feed; + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color('#FFFFFF'), + glyphName: 'cyclone', + rotate: false, + alwaysGlyph: true, + glyphScale: 1.1, + dotScale: 1.3, + }); + this.lines = this.root.add(new Cesium.PolylineCollection()); + this.records = new Map(); + } + + async refresh() { + try { + await this.feed.get(); + } finally { + this.fromFeed(this.feed); + } + this._draw(true); + } + + _draw(full) { + const data = this.feed.data; + if (!data) return; + const t = this.ctx.timeMs(); + const items = []; + this.records = new Map(); + for (const s of data.records) { + if (t < s.t - 6 * 86_400_000) continue; + const p = stormPositionAt(s, t); + if (!p) continue; + this.records.set(s.id, { ...s, now: p }); + items.push({ + key: s.id, + lon: p.lon, + lat: p.lat, + altM: 0, + color: color(p.forecast ? '#FFD2C2' : '#FFFFFF', p.forecast ? 0.8 : 1), + glyphScale: 0.8 + Math.min(1, (p.windKt || 40) / 140) * 0.7, + }); + } + this.count = items.length; + this.renderer.setItems(items); + if (full) this._lines(data.records); + } + + _lines(storms) { + this.lines.removeAll(); + const dash = (css, a) => + Cesium.Material.fromType('PolylineDash', { + color: color(css, a), + dashLength: 14, + }); + for (const s of storms) { + if (s.forecast?.length > 1) { + this.lines.add({ + positions: Cesium.Cartesian3.fromDegreesArray( + [{ lon: s.lon, lat: s.lat }, ...s.forecast].flatMap((p) => [ + p.lon, + p.lat, + ]), + ), + width: 2.5, + material: dash('#FFFFFF', 0.85), + }); + } + const cone = outerRing(s.cone); + if (cone) { + this.lines.add({ + positions: Cesium.Cartesian3.fromDegreesArray(cone.flat()), + width: 1.5, + material: Cesium.Material.fromType('Color', { + color: color(HAZ, 0.7), + }), + }); + } + if (s.pastTrack?.length > 1) { + this.lines.add({ + positions: Cesium.Cartesian3.fromDegreesArray( + s.pastTrack.flatMap((p) => [p.lon, p.lat]), + ), + width: 2, + material: Cesium.Material.fromType('Color', { + color: color('#FFFFFF', 0.6), + }), + }); + } + } + } + + onView(view) { + this.renderer.setView(view); + } + onTime() { + if (this.on) this._draw(false); + } + locate(key) { + const s = this.records.get(key); + return s ? [s.now.lon, s.now.lat, 0] : null; + } + trail(key) { + const s = this.records.get(key); + if (!s) return []; + return ( + s.pastTrack || [{ lon: s.lon, lat: s.lat }, ...(s.forecast || [])] + ).map((p) => [p.lon, p.lat, 0]); + } + pick(key) { + const s = this.records.get(key); + if (!s) return null; + const label = + s.label || + { + HU: 'Hurricane', + TS: 'Tropical storm', + TD: 'Tropical depression', + TY: 'Typhoon', + TC: 'Cyclone', + STS: 'Subtropical storm', + }[s.classification] || + 'Cyclone'; + const wind = s.now.windKt + ? `${Math.round(s.now.windKt * 1.852)} km/h` + : null; + return { + kind: 'cyclone', + id: s.id, + title: `${label} ${s.name}`, + subtitle: [ + wind, + s.pressureHpa ? `${s.pressureHpa} hPa` : null, + s.now.forecast ? 'forecast' : 'observed', + ] + .filter(Boolean) + .join(' · '), + lon: s.now.lon, + lat: s.now.lat, + meta: { + layer: this.id, + windKt: s.now.windKt ?? null, + pressureHpa: s.pressureHpa ?? null, + movement: s.movement ?? null, + basin: s.basin, + url: s.link, + source: s.source, + }, + }; + } +} + +/** Elevation of the corridor vertex nearest a point (0 without a corridor). */ +function nearestElevation(corridor, lon, lat) { + if (!corridor?.length) return 0; + let best = corridor[0]; + let bestD = Infinity; + for (const c of corridor) { + const d = (c[0] - lon) ** 2 + (c[1] - lat) ** 2; + if (d < bestD) { + bestD = d; + best = c; + } + } + return best[2] || 0; +} + +function outerRing(geom) { + if (!geom) return null; + if (geom.type === 'Polygon') return geom.coordinates[0]; + if (geom.type === 'MultiPolygon') { + let best = null; + for (const poly of geom.coordinates) + if (!best || poly[0].length > best.length) best = poly[0]; + return best; + } + return null; +} + +/* ───────────────────────── Bhote Koshi flood ───────────────────────── */ + +class FloodLayer extends Layer { + constructor(ctx, { feed }) { + super(ctx, { id: 'floods', family: 'Hazards', name: 'Floods' }); + this.feed = feed; + this.lines = this.root.add(new Cesium.PolylineCollection()); + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(HAZ), + glyphName: 'pin', + rotate: false, + alwaysGlyph: true, + glyphScale: 0.62, + }); + this.anchors = new Map(); + this.overlay = null; + } + + async refresh() { + const data = await this.feed.get(); + this.status = 'live'; + this.source = data.source; + const corridor = data.corridor || data.path; + this._drawLines(data, null); + this._sampleTerrain(data); + const ev = data.event; + const items = []; + this.anchors = new Map(); + const head = corridor[0]; + this.anchors.set('bhote-koshi', { + id: 'bhote-koshi', + title: ev?.title || 'Bhote Koshi outburst flood', + subtitle: `Observed ${ev?.observedDate || '2026-08-26'} · reconstruction`, + lon: head[0], + lat: head[1], + meta: { caveat: ev?.reconstruction?.caveat || null }, + }); + for (const a of ev?.witnessAnchors || []) { + this.anchors.set(a.id, { + id: a.id, + title: a.title, + subtitle: (a.details || []).join(' · '), + lon: a.lon, + lat: a.lat, + meta: { url: a.sourceUrl || null }, + }); + } + for (const a of this.anchors.values()) + items.push({ + key: a.id, + lon: a.lon, + lat: a.lat, + altM: nearestElevation(data.corridor, a.lon, a.lat) + 30, + glyphScale: a.id === 'bhote-koshi' ? 1.2 : 0.8, + }); + this.renderer.setItems(items); + this.count = 1; + } + + /** + * The corridor carries surveyed elevations; the longer GeoPera centreline + * has none, so it takes terrain heights once terrain is available (until + * then, the nearest corridor elevation). Lines sit 20 m above ground so + * they neither sink into terrain nor float in parallax. + */ + _drawLines(data, pathHeights) { + this.lines.removeAll(); + const glow = (css, power, alpha) => + Cesium.Material.fromType('PolylineGlow', { + color: color(css, alpha), + glowPower: power, + taperPower: 1, + }); + const pathPositions = data.path.flatMap(([lon, lat], i) => [ + lon, + lat, + (pathHeights?.[i] ?? nearestElevation(data.corridor, lon, lat)) + 20, + ]); + this.lines.add({ + positions: Cesium.Cartesian3.fromDegreesArrayHeights(pathPositions), + width: 5, + material: glow('#5EC8FF', 0.16, 0.85), + }); + if (data.corridor) + this.lines.add({ + positions: Cesium.Cartesian3.fromDegreesArrayHeights( + data.corridor.flatMap(([lon, lat, h]) => [lon, lat, (h || 0) + 20]), + ), + width: 7, + material: glow('#A6ECFF', 0.14, 0.9), + }); + this.ctx.requestRender(); + } + + async _sampleTerrain(data) { + if (this._sampled) return; + this._sampled = true; + const viewer = this.ctx.viewer; + // Terrain arrives asynchronously after boot. + for ( + let i = 0; + i < 20 && + !(viewer.terrainProvider instanceof Cesium.CesiumTerrainProvider); + i++ + ) + await new Promise((r) => setTimeout(r, 1000)); + if (!(viewer.terrainProvider instanceof Cesium.CesiumTerrainProvider)) + return; + try { + const cartos = data.path.map(([lon, lat]) => + Cesium.Cartographic.fromDegrees(lon, lat), + ); + const sampled = await Cesium.sampleTerrainMostDetailed( + viewer.terrainProvider, + cartos, + ); + if (viewer.isDestroyed()) return; + this._drawLines( + data, + sampled.map((c) => (Number.isFinite(c.height) ? c.height : null)), + ); + } catch (error) { + console.warn('[globe:floods] terrain sample', error); + } + } + + /** The 1 MB post-event image loads only once the camera is close. */ + _overlay() { + const ev = this.feed.data?.event; + const rect = ev?.imagery?.rectangle; + const path = ev?.imagery?.after?.path; + if (!rect || !path || this.overlay) return; + const provider = Cesium.SingleTileImageryProvider.fromUrl( + `/events/bhote-koshi-2026/${path}`, + { + rectangle: Cesium.Rectangle.fromDegrees( + rect.west, + rect.south, + rect.east, + rect.north, + ), + credit: ev.imagery.after.source, + }, + ); + this.overlay = Cesium.ImageryLayer.fromProviderAsync(provider, { + alpha: 0.95, + }); + this.overlay.show = this.on; + this.ctx.viewer.imageryLayers.add(this.overlay); + } + + setOn(on) { + super.setOn(on); + if (this.overlay) this.overlay.show = Boolean(on); + } + + onView(view) { + this.renderer.setView(view); + if (this.on && view.heightM < 400_000) this._overlay(); + } + locate(key) { + const a = this.anchors.get(key); + return a ? [a.lon, a.lat, 0] : null; + } + trail() { + return this.feed.data?.path?.map(([lon, lat]) => [lon, lat, 0]) || []; + } + pick(key) { + const a = this.anchors.get(key); + if (!a) return null; + return { + kind: 'flood', + ...a, + meta: { layer: this.id, source: this.source, ...a.meta }, + }; + } +} diff --git a/mobile/globe/layers/orbit.js b/mobile/globe/layers/orbit.js new file mode 100644 index 000000000..a4205788a --- /dev/null +++ b/mobile/globe/layers/orbit.js @@ -0,0 +1,435 @@ +/** + * Orbit family: satellites (CelesTrak TLE + SGP4), ISS & crewed stations with + * a live orbit ribbon, Starlink, and launches. Positions follow the scene + * clock, so scrubbing the time ribbon flies every satellite to that instant. + */ +import * as Cesium from 'cesium'; +import { + twoline2satrec, + propagate, + gstime, + eciToGeodetic, + degreesLat, + degreesLong, +} from 'satellite.js'; +import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js'; +import { relTime } from '../geo.js'; + +const ORBIT = FAMILY_COLORS.Orbit; +const CREWED = new Set([25544, 48274]); // ISS (ZARYA), CSS (TIANHE) + +/** Sub-satellite point and height for a satrec at a time, or null. */ +export function satPosition(satrec, ms) { + const date = new Date(ms); + let pv; + try { + pv = propagate(satrec, date); + } catch { + return null; + } + const p = pv?.position; + if (!p || typeof p === 'boolean' || !Number.isFinite(p.x)) return null; + const g = eciToGeodetic(p, gstime(date)); + const v = pv.velocity; + return { + lon: degreesLong(g.longitude), + lat: degreesLat(g.latitude), + altM: g.height * 1000, + speedMps: v ? Math.hypot(v.x, v.y, v.z) * 1000 : null, + }; +} + +/** Ground-track heading (deg) from two nearby samples. */ +function trackHeading(satrec, ms) { + const a = satPosition(satrec, ms); + const b = satPosition(satrec, ms + 10_000); + if (!a || !b) return 0; + const y = + Math.sin(((b.lon - a.lon) * Math.PI) / 180) * + Math.cos((b.lat * Math.PI) / 180); + const x = + Math.cos((a.lat * Math.PI) / 180) * Math.sin((b.lat * Math.PI) / 180) - + Math.sin((a.lat * Math.PI) / 180) * + Math.cos((b.lat * Math.PI) / 180) * + Math.cos(((b.lon - a.lon) * Math.PI) / 180); + return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360; +} + +export class SatelliteLayer extends Layer { + constructor(ctx, def, opts) { + super(ctx, { pollMs: 3 * 3600_000, ...def }); + this.feeds = opts.feeds; + this.filter = opts.filter || (() => true); + this.kind = opts.kind || 'satellite'; + this.slice = opts.slice ?? Infinity; + this.orbitRibbon = Boolean(opts.orbitRibbon); + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(opts.tint || ORBIT), + glyphName: opts.glyph || 'sat', + lodHeightM: opts.lodHeightM ?? 9_000_000, + cap: opts.cap ?? 250, + rotate: false, + dotScale: opts.dotScale ?? 0.32, + glyphScale: opts.glyphScale ?? 0.42, + alwaysGlyph: opts.alwaysGlyph, + }); + this.sats = []; // { key, name, satrec, norad } + this.byKey = new Map(); + this._rr = 0; + this._lastMs = 0; + if (this.orbitRibbon) { + this.ribbons = this.root.add(new Cesium.PolylineCollection()); + } + } + + async refresh() { + const results = await Promise.allSettled(this.feeds.map((f) => f.get())); + const seen = new Set(); + const sats = []; + let ok = false; + this.feeds.forEach((feed, i) => { + this.fromFeed(feed); + if (results[i].status !== 'fulfilled') return; + ok = true; + for (const e of results[i].value.entries) { + const norad = Number(e.line1.slice(2, 7)); + const key = String(e.line1.slice(2, 7).trim()); + if (seen.has(key) || !this.filter(e, norad)) continue; + seen.add(key); + let satrec; + try { + satrec = twoline2satrec(e.line1, e.line2); + } catch { + continue; + } + sats.push({ + key, + name: e.name.replace(/^0 /, '').trim(), + satrec, + norad, + }); + } + }); + if (!ok) throw results[0].reason; + this.sats = sats; + this.byKey = new Map(sats.map((s) => [s.key, s])); + this._place(this.ctx.timeMs(), true); + } + + _place(ms, full) { + const items = []; + const keep = []; + for (const s of this.sats) { + const p = satPosition(s.satrec, ms); + if (!p) continue; + s.pos = p; + keep.push(s); + const crewed = CREWED.has(s.norad); + items.push({ + key: s.key, + lon: p.lon, + lat: p.lat, + altM: p.altM, + glyph: s.norad === 25544 ? 'iss' : undefined, + glyphScale: s.norad === 25544 ? 1.6 : crewed ? 1.2 : 1, + dotScale: crewed ? 2.2 : 1, + color: crewed ? color('#FFFFFF') : undefined, + }); + } + this.count = keep.length; + if (full) this.renderer.setItems(items); + this._lastMs = ms; + this._drawRibbons(ms); + } + + _drawRibbons(ms) { + if (!this.ribbons) return; + this.ribbons.removeAll(); + for (const s of this.sats) { + if (!CREWED.has(s.norad)) continue; + const positions = []; + // One orbit ahead, 30 min behind, sampled every 60 s. + for (let t = -30; t <= 93; t++) { + const p = satPosition(s.satrec, ms + t * 60_000); + if (p) + positions.push(Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.altM)); + } + if (positions.length < 2) continue; + this.ribbons.add({ + positions, + width: s.norad === 25544 ? 3 : 2, + material: Cesium.Material.fromType('PolylineGlow', { + color: color(s.norad === 25544 ? '#E9DDFF' : ORBIT, 0.32), + glowPower: 0.18, + taperPower: 1, + }), + }); + } + } + + /** Propagate to `ms`: the glyph-visible satellites plus a round-robin slice. */ + _advance(ms) { + const items = this.renderer.items; + if (!items.length) return false; + const n = items.length; + const slice = Math.min(n, this.slice); + const from = this._rr % n; + const to = Math.min(n, from + slice); + for (let i = from; i < to; i++) this._updateItem(items[i], ms); + for (let i = 0; i < this.renderer.glyphs.length; i++) { + const it = this.renderer.byKey.get(this.renderer.glyphs.get(i).id.key); + if (it) this._updateItem(it, ms); + } + this.renderer.moveItems(from, to); + this._rr = to >= n ? 0 : to; + return true; + } + + _updateItem(it, ms) { + const s = this.byKey.get(it.key); + const p = s && satPosition(s.satrec, ms); + if (!p) return; + s.pos = p; + it.lon = p.lon; + it.lat = p.lat; + it.altM = p.altM; + } + + onView(view) { + this.renderer.setView(view); + } + + onTime(ms) { + if (!this.sats.length) return; + if (this.sats.length <= 3000) { + for (const it of this.renderer.items) this._updateItem(it, ms); + this.renderer.moveItems(); + } else this._advance(ms); + this._lastMs = ms; + this._drawRibbons(ms); + } + + onTick(nowMs, live, clockMs) { + if (!this.sats.length) return false; + if (Math.abs(clockMs - this._lastMs) < 900) return false; + this._advance(clockMs); + if (this.ribbons && Math.abs(clockMs - (this._ribbonMs || 0)) > 60_000) { + this._ribbonMs = clockMs; + this._drawRibbons(clockMs); + } + this._lastMs = clockMs; + return true; + } + + locate(key) { + const s = this.byKey.get(key); + const p = s && satPosition(s.satrec, this.ctx.timeMs()); + return p ? [p.lon, p.lat, p.altM] : null; + } + + trail(key) { + const s = this.byKey.get(key); + if (!s) return []; + const ms = this.ctx.timeMs(); + const out = []; + for (let t = -45; t <= 45; t += 1) { + const p = satPosition(s.satrec, ms + t * 60_000); + if (p) out.push([p.lon, p.lat, p.altM]); + } + return out; + } + + satrecFor(norad) { + return this.sats.find((s) => s.norad === norad)?.satrec || null; + } + + pick(key) { + const s = this.byKey.get(key); + if (!s) return null; + const p = satPosition(s.satrec, this.ctx.timeMs()); + if (!p) return null; + return { + kind: this.kind, + id: s.key, + title: s.name, + subtitle: `NORAD ${s.norad} · ${Math.round(p.altM / 1000)} km · ${(p.speedMps / 1000).toFixed(1)} km/s`, + lon: p.lon, + lat: p.lat, + meta: { + layer: this.id, + norad: s.norad, + altitudeM: p.altM, + speedMps: p.speedMps, + headingDeg: trackHeading(s.satrec, this.ctx.timeMs()), + source: this.source, + }, + }; + } + + contacts() { + const ms = this.ctx.timeMs(); + return this.sats + .filter((s) => s.pos) + .map((s) => ({ + kind: this.kind, + id: s.key, + title: s.name, + lon: s.pos.lon, + lat: s.pos.lat, + altM: s.pos.altM, + headingDeg: CREWED.has(s.norad) ? trackHeading(s.satrec, ms) : null, + layer: this.id, + })); + } +} + +/** Per pad: the next pending launch beats any past one; else the latest. */ +function betterLaunch(a, b, t) { + const ap = a.net >= t; + const bp = b.net >= t; + if (ap !== bp) return ap; + return ap ? a.net < b.net : a.net > b.net; +} + +export class LaunchLayer extends Layer { + constructor(ctx, def, { feed }) { + super(ctx, { pollMs: 30 * 60_000, ...def }); + this.feed = feed; + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(ORBIT), + glyphName: 'rocket', + rotate: false, + alwaysGlyph: true, + glyphScale: 0.62, + dotScale: 0.8, + }); + this.records = new Map(); + } + + async refresh() { + try { + await this.feed.get(); + } finally { + this.fromFeed(this.feed); + } + this._draw(); + } + + _draw() { + const data = this.feed.data; + if (!data) return; + const t = this.ctx.timeMs(); + const items = []; + this.records = new Map(); + // One marker per pad: the next launch from it, or its most recent one. + const byPad = new Map(); + for (const r of data.records) { + const key = `${r.lat.toFixed(3)},${r.lon.toFixed(3)}`; + const cur = byPad.get(key); + if (!cur || betterLaunch(r, cur, t)) byPad.set(key, r); + } + for (const r of byPad.values()) { + this.records.set(r.id, r); + const pending = r.net > t; + const soon = pending && r.net - t < 7 * 86_400_000; + items.push({ + key: r.id, + lon: r.lon, + lat: r.lat, + altM: 0, + color: pending + ? color(soon ? '#FFFFFF' : ORBIT, soon ? 1 : 0.85) + : color(ORBIT, 0.45), + glyphScale: soon ? 1.25 : 0.9, + }); + } + this.count = data.records.filter((r) => r.net > t).length; + this.renderer.setItems(items); + } + + onView(view) { + this.renderer.setView(view); + } + + onTime() { + this._draw(); + } + + locate(key) { + const r = this.records.get(key); + return r ? [r.lon, r.lat, 0] : null; + } + + pick(key) { + const r = this.records.get(key); + if (!r) return null; + const t = this.ctx.timeMs(); + return { + kind: 'launch', + id: r.id, + title: r.name, + subtitle: `${r.provider || r.pad} · ${r.net > t ? 'T−' : ''}${relTime(r.net, t)}${r.status ? ` · ${r.status}` : ''}`, + lon: r.lon, + lat: r.lat, + meta: { + layer: this.id, + net: new Date(r.net).toISOString(), + pad: r.pad, + location: r.location, + mission: r.mission, + orbit: r.orbit, + image: r.image, + status: r.status, + source: this.source, + }, + }; + } +} + +export function createOrbitLayers( + ctx, + { visual, stations, gps, starlink, launches }, +) { + return [ + new SatelliteLayer( + ctx, + { id: 'satellites', family: 'Orbit', name: 'Satellites (TLE)' }, + { + feeds: [visual, gps], + filter: (e, norad) => !CREWED.has(norad), + dotScale: 0.34, + }, + ), + new SatelliteLayer( + ctx, + { id: 'iss', family: 'Orbit', name: 'ISS & crewed' }, + { + feeds: [stations], + filter: (e, norad) => CREWED.has(norad), + kind: 'station', + alwaysGlyph: true, + orbitRibbon: true, + glyph: 'sat', + }, + ), + new SatelliteLayer( + ctx, + { id: 'starlink', family: 'Orbit', name: 'Starlink trains' }, + { + feeds: [starlink], + slice: 1500, + dotScale: 0.22, + tint: '#B9A2F0', + cap: 200, + lodHeightM: 3_000_000, + }, + ), + new LaunchLayer( + ctx, + { id: 'launches', family: 'Orbit', name: 'Launches & trajectories' }, + { feed: launches }, + ), + ]; +} diff --git a/mobile/globe/layers/sea.js b/mobile/globe/layers/sea.js new file mode 100644 index 000000000..68ac26620 --- /dev/null +++ b/mobile/globe/layers/sea.js @@ -0,0 +1,262 @@ +/** + * Sea family (AIS vessels, tankers/cargo, ports) and Eyes family (public + * CCTV). AIS needs the server's AISSTREAM_API_KEY; without it the layers say + * so instead of pretending the ocean is empty. + */ +import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js'; +import { PORTS } from '../places.js'; +import { cctvFrameUrl } from '../feeds.js'; + +const SEA = FAMILY_COLORS.Sea; +const EYES = FAMILY_COLORS.Eyes; +const MPS_TO_KT = 1 / 0.514444; + +class VesselLayer extends Layer { + constructor(ctx, def, { feed, filter, tint }) { + super(ctx, { pollMs: 20_000, ...def }); + this.feed = feed; + this.filter = filter; + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(tint), + glyphName: 'ship', + lodHeightM: 1_200_000, + cap: 800, + deadReckon: true, + dotScale: 0.34, + glyphScale: 0.42, + }); + this.records = new Map(); + } + + async refresh() { + try { + await this.feed.get({ maxAgeMs: 15_000 }); + } catch (error) { + this.fromFeed(this.feed); + if (error.keyRequired) { + this.status = 'error'; + this.message = error.message; + this.count = 0; + return; + } + throw error; + } + this.fromFeed(this.feed); + const records = this.feed.data.records.filter(this.filter); + this.records = new Map(records.map((r) => [r.id, r])); + this.count = records.length; + const now = Date.now(); + this.renderer.setItems( + records.map((r) => ({ + key: r.id, + lon: r.lon, + lat: r.lat, + heading: r.heading, + speed: r.speed, + seenAt: now, + })), + ); + } + + onView(view) { + this.renderer.setView(view); + } + onTime(ms, live) { + this.renderer.setAlpha(live ? 1 : 0.3); + } + onTick(nowMs, live) { + return live && this.renderer.tick(nowMs); + } + locate(key) { + const it = this.renderer.byKey.get(key); + if (!it) return null; + const [lon, lat] = this.renderer.where(it); + return [lon, lat, 0]; + } + pick(key) { + const r = this.records.get(key); + if (!r) return null; + const [lon, lat] = this.locate(key); + return { + kind: 'vessel', + id: r.id, + title: r.name, + subtitle: [ + r.type || 'Vessel', + Number.isFinite(r.speed) + ? `${(r.speed * MPS_TO_KT).toFixed(1)} kn` + : null, + r.destination ? `→ ${r.destination}` : null, + ] + .filter(Boolean) + .join(' · '), + lon, + lat, + meta: { + layer: this.id, + mmsi: r.id, + type: r.type, + speedMps: r.speed, + headingDeg: r.heading, + destination: r.destination, + source: this.source, + }, + }; + } + contacts() { + return [...this.records.values()].map((r) => { + const [lon, lat] = this.locate(r.id) || [r.lon, r.lat]; + return { + kind: 'vessel', + id: r.id, + title: r.name, + lon, + lat, + altM: 0, + headingDeg: r.heading, + speedMps: r.speed, + layer: this.id, + }; + }); + } +} + +class PortsLayer extends Layer { + constructor(ctx) { + super(ctx, { id: 'ports', family: 'Sea', name: 'Ports' }); + this.source = 'Bundled reference (2024 throughput, M TEU)'; + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(SEA), + glyphName: 'port', + rotate: false, + alwaysGlyph: true, + glyphScale: 0.5, + }); + this.records = new Map(PORTS.map((p) => [p.id, p])); + } + async refresh() { + this.status = 'live'; + this.count = PORTS.length; + this.renderer.setItems( + PORTS.map((p) => ({ + key: p.id, + lon: p.lon, + lat: p.lat, + glyphScale: 0.7 + Math.min(1, p.teu / 40) * 0.6, + })), + ); + } + onView(view) { + this.renderer.setView(view); + } + locate(key) { + const p = this.records.get(key); + return p ? [p.lon, p.lat, 0] : null; + } + pick(key) { + const p = this.records.get(key); + if (!p) return null; + return { + kind: 'port', + id: p.id, + title: p.name, + subtitle: `${p.teu.toFixed(1)} M TEU / year`, + lon: p.lon, + lat: p.lat, + meta: { layer: this.id, teu: p.teu, source: this.source }, + }; + } +} + +class CctvLayer extends Layer { + constructor(ctx, { feed }) { + super(ctx, { + id: 'cctv', + family: 'Eyes', + name: 'Public CCTV', + pollMs: 30 * 60_000, + }); + this.feed = feed; + this.renderer = new ContactRenderer(this.root, { + layerId: this.id, + tint: color(EYES), + glyphName: 'camera', + lodHeightM: 250_000, + cap: 250, + dotScale: 0.3, + glyphScale: 0.45, + }); + this.records = new Map(); + } + async refresh() { + try { + await this.feed.get(); + } finally { + this.fromFeed(this.feed); + } + const records = this.feed.data.records; + this.records = new Map(records.map((r) => [r.id, r])); + this.count = records.length; + this.renderer.setItems( + records.map((r) => ({ + key: r.id, + lon: r.lon, + lat: r.lat, + altM: 0, + heading: r.heading, + })), + ); + } + onView(view) { + this.renderer.setView(view); + } + locate(key) { + const r = this.records.get(key); + return r ? [r.lon, r.lat, 0] : null; + } + pick(key) { + const r = this.records.get(key); + if (!r) return null; + return { + kind: 'camera', + id: r.id, + title: r.name, + subtitle: [r.city, r.provider].filter(Boolean).join(' · '), + lon: r.lon, + lat: r.lat, + meta: { + layer: this.id, + frameUrl: cctvFrameUrl(r), + headingDeg: r.heading, + fovDeg: r.fovDeg, + feedType: r.feedType, + provider: r.provider, + source: this.source, + }, + }; + } +} + +const TANKER = /tanker|cargo|container|bulk/i; + +export function createSeaLayers(ctx, { vessels }) { + return [ + new VesselLayer( + ctx, + { id: 'vessels', family: 'Sea', name: 'AIS vessels' }, + { feed: vessels, filter: () => true, tint: SEA }, + ), + new PortsLayer(ctx), + new VesselLayer( + ctx, + { id: 'tankers', family: 'Sea', name: 'Tankers / cargo' }, + { feed: vessels, filter: (r) => TANKER.test(r.type), tint: '#7FF0DC' }, + ), + ]; +} + +export function createEyesLayers(ctx, { cctv }) { + return [new CctvLayer(ctx, { feed: cctv })]; +} diff --git a/mobile/globe/places.js b/mobile/globe/places.js new file mode 100644 index 000000000..96de1cf40 --- /dev/null +++ b/mobile/globe/places.js @@ -0,0 +1,288 @@ +/** + * Bundled reference places: major ports (the Ports layer) and the "Always on" + * evergreen situations. Coordinates are public, rounded, and need no feed. + */ + +export const PORTS = Object.freeze([ + { + id: 'shanghai', + name: 'Port of Shanghai', + lon: 121.9, + lat: 30.63, + teu: 49.0, + }, + { + id: 'singapore', + name: 'Port of Singapore', + lon: 103.84, + lat: 1.26, + teu: 39.0, + }, + { id: 'ningbo', name: 'Ningbo-Zhoushan', lon: 121.85, lat: 29.93, teu: 35.3 }, + { + id: 'shenzhen', + name: 'Port of Shenzhen', + lon: 113.88, + lat: 22.49, + teu: 30.0, + }, + { id: 'qingdao', name: 'Port of Qingdao', lon: 120.25, lat: 36.0, teu: 28.8 }, + { + id: 'guangzhou', + name: 'Guangzhou Nansha', + lon: 113.6, + lat: 22.72, + teu: 25.4, + }, + { id: 'busan', name: 'Port of Busan', lon: 129.04, lat: 35.08, teu: 23.0 }, + { + id: 'tianjin', + name: 'Port of Tianjin', + lon: 117.78, + lat: 38.98, + teu: 22.0, + }, + { + id: 'hong-kong', + name: 'Port of Hong Kong', + lon: 114.12, + lat: 22.33, + teu: 14.0, + }, + { + id: 'rotterdam', + name: 'Port of Rotterdam', + lon: 4.05, + lat: 51.95, + teu: 13.4, + }, + { id: 'jebel-ali', name: 'Jebel Ali', lon: 55.03, lat: 25.01, teu: 14.5 }, + { id: 'port-klang', name: 'Port Klang', lon: 101.32, lat: 2.99, teu: 14.0 }, + { + id: 'antwerp', + name: 'Port of Antwerp-Bruges', + lon: 4.3, + lat: 51.28, + teu: 12.5, + }, + { id: 'xiamen', name: 'Port of Xiamen', lon: 118.07, lat: 24.47, teu: 12.6 }, + { + id: 'tanjung-pelepas', + name: 'Tanjung Pelepas', + lon: 103.55, + lat: 1.36, + teu: 10.5, + }, + { + id: 'kaohsiung', + name: 'Port of Kaohsiung', + lon: 120.28, + lat: 22.6, + teu: 9.5, + }, + { + id: 'los-angeles', + name: 'Port of Los Angeles', + lon: -118.26, + lat: 33.73, + teu: 9.9, + }, + { + id: 'long-beach', + name: 'Port of Long Beach', + lon: -118.21, + lat: 33.75, + teu: 9.6, + }, + { id: 'hamburg', name: 'Port of Hamburg', lon: 9.93, lat: 53.53, teu: 7.7 }, + { + id: 'new-york', + name: 'Port of New York & New Jersey', + lon: -74.14, + lat: 40.67, + teu: 8.7, + }, + { + id: 'laem-chabang', + name: 'Laem Chabang', + lon: 100.88, + lat: 13.08, + teu: 8.9, + }, + { id: 'tanger-med', name: 'Tanger Med', lon: -5.5, lat: 35.89, teu: 8.6 }, + { id: 'colombo', name: 'Port of Colombo', lon: 79.85, lat: 6.95, teu: 7.2 }, + { + id: 'valencia', + name: 'Port of Valencia', + lon: -0.31, + lat: 39.44, + teu: 5.5, + }, + { id: 'piraeus', name: 'Port of Piraeus', lon: 23.61, lat: 37.94, teu: 5.1 }, + { id: 'santos', name: 'Port of Santos', lon: -46.3, lat: -23.97, teu: 5.0 }, + { + id: 'savannah', + name: 'Port of Savannah', + lon: -81.14, + lat: 32.12, + teu: 5.6, + }, + { id: 'mundra', name: 'Mundra', lon: 69.7, lat: 22.74, teu: 7.4 }, + { + id: 'jeddah', + name: 'Jeddah Islamic Port', + lon: 39.15, + lat: 21.47, + teu: 4.8, + }, + { + id: 'felixstowe', + name: 'Port of Felixstowe', + lon: 1.32, + lat: 51.95, + teu: 3.7, + }, +]); + +/** Evergreen "Always on" situations. */ +export const EVERGREEN = Object.freeze([ + { + id: 'rotterdam', + title: 'Port of Rotterdam', + subtitle: "Europe's largest port · Maasvlakte to the Botlek", + place: 'Rotterdam, Netherlands', + lon: 4.12, + lat: 51.95, + radiusKm: 16, + camera: { height: 14_000, heading: 80, pitch: -38 }, + layers: ['vessels', 'ports', 'flights'], + family: 'Sea', + }, + { + id: 'jfk', + title: 'JFK at rush hour', + subtitle: 'Arrivals stacking over Jamaica Bay', + place: 'New York, USA', + lon: -73.7781, + lat: 40.6413, + radiusKm: 18, + camera: { height: 9_000, heading: 30, pitch: -35 }, + layers: ['flights', 'ga', 'helicopters'], + family: 'Sky', + airport: true, + }, + { + id: 'hormuz', + title: 'Strait of Hormuz', + subtitle: 'A fifth of the world’s oil passes this 33 km neck', + place: 'Strait of Hormuz', + lon: 56.45, + lat: 26.55, + radiusKm: 70, + camera: { height: 160_000, heading: 330, pitch: -55 }, + layers: ['vessels', 'tankers', 'military'], + family: 'Sea', + }, + { + id: 'singapore-strait', + title: 'Singapore Strait', + subtitle: 'Ships queuing for the world’s busiest transhipment hub', + place: 'Singapore', + lon: 103.85, + lat: 1.22, + radiusKm: 22, + camera: { height: 30_000, heading: 20, pitch: -45 }, + layers: ['vessels', 'ports'], + family: 'Sea', + }, + { + id: 'suez', + title: 'Suez Canal', + subtitle: 'Northbound convoy at Port Said', + place: 'Egypt', + lon: 32.35, + lat: 30.6, + radiusKm: 45, + camera: { height: 90_000, heading: 350, pitch: -50 }, + layers: ['vessels', 'tankers'], + family: 'Sea', + }, + { + id: 'panama', + title: 'Panama Canal', + subtitle: 'Gatun locks and the anchorage off Colón', + place: 'Panama', + lon: -79.82, + lat: 9.2, + radiusKm: 30, + camera: { height: 60_000, heading: 320, pitch: -50 }, + layers: ['vessels'], + family: 'Sea', + }, + { + id: 'heathrow', + title: 'Heathrow holding stacks', + subtitle: 'Bovingdon, Lambourne, Ockham, Biggin', + place: 'London, UK', + lon: -0.4543, + lat: 51.47, + radiusKm: 30, + camera: { height: 45_000, heading: 90, pitch: -50 }, + layers: ['flights', 'ga'], + family: 'Sky', + airport: true, + }, + { + id: 'cape-canaveral', + title: 'Cape Canaveral', + subtitle: 'Space Coast pads LC-39A and SLC-40', + place: 'Florida, USA', + lon: -80.6, + lat: 28.56, + radiusKm: 18, + camera: { height: 16_000, heading: 0, pitch: -40 }, + layers: ['launches', 'satellites'], + family: 'Orbit', + }, +]); + +/** Busy airports used to find the busiest airspace right now. */ +export const AIRPORTS = Object.freeze([ + { code: 'ATL', name: 'Atlanta Hartsfield-Jackson', lon: -84.43, lat: 33.64 }, + { code: 'DXB', name: 'Dubai International', lon: 55.36, lat: 25.25 }, + { code: 'DFW', name: 'Dallas/Fort Worth', lon: -97.04, lat: 32.9 }, + { code: 'LHR', name: 'London Heathrow', lon: -0.45, lat: 51.47 }, + { code: 'HND', name: 'Tokyo Haneda', lon: 139.78, lat: 35.55 }, + { code: 'DEN', name: 'Denver International', lon: -104.67, lat: 39.86 }, + { code: 'ORD', name: "Chicago O'Hare", lon: -87.9, lat: 41.98 }, + { code: 'IST', name: 'Istanbul Airport', lon: 28.74, lat: 41.26 }, + { code: 'LAX', name: 'Los Angeles International', lon: -118.41, lat: 33.94 }, + { code: 'CDG', name: 'Paris Charles de Gaulle', lon: 2.55, lat: 49.01 }, + { code: 'AMS', name: 'Amsterdam Schiphol', lon: 4.76, lat: 52.31 }, + { code: 'FRA', name: 'Frankfurt', lon: 8.57, lat: 50.04 }, + { code: 'JFK', name: 'New York JFK', lon: -73.78, lat: 40.64 }, + { code: 'SIN', name: 'Singapore Changi', lon: 103.99, lat: 1.36 }, + { code: 'ICN', name: 'Seoul Incheon', lon: 126.45, lat: 37.46 }, + { code: 'PEK', name: 'Beijing Capital', lon: 116.6, lat: 40.08 }, + { code: 'PVG', name: 'Shanghai Pudong', lon: 121.81, lat: 31.14 }, + { code: 'CAN', name: 'Guangzhou Baiyun', lon: 113.3, lat: 23.39 }, + { code: 'DEL', name: 'Delhi Indira Gandhi', lon: 77.1, lat: 28.56 }, + { code: 'MAD', name: 'Madrid Barajas', lon: -3.57, lat: 40.49 }, + { code: 'CLT', name: 'Charlotte Douglas', lon: -80.94, lat: 35.21 }, + { code: 'LAS', name: 'Las Vegas Harry Reid', lon: -115.15, lat: 36.08 }, + { code: 'MIA', name: 'Miami International', lon: -80.29, lat: 25.79 }, + { code: 'SFO', name: 'San Francisco', lon: -122.38, lat: 37.62 }, + { code: 'BKK', name: 'Bangkok Suvarnabhumi', lon: 100.75, lat: 13.69 }, + { code: 'GRU', name: 'São Paulo Guarulhos', lon: -46.47, lat: -23.43 }, + { code: 'SYD', name: 'Sydney Kingsford Smith', lon: 151.18, lat: -33.95 }, + { code: 'MEX', name: 'Mexico City', lon: -99.07, lat: 19.44 }, + { code: 'DOH', name: 'Doha Hamad', lon: 51.61, lat: 25.27 }, + { code: 'BOM', name: 'Mumbai', lon: 72.87, lat: 19.09 }, +]); + +/** Fort Novosel (formerly Fort Rucker), US Army helicopter flight school. */ +export const FORT_NOVOSEL = Object.freeze({ + lon: -85.71, + lat: 31.35, + radiusKm: 60, +}); diff --git a/mobile/globe/post.js b/mobile/globe/post.js new file mode 100644 index 000000000..feeba8a1a --- /dev/null +++ b/mobile/globe/post.js @@ -0,0 +1,289 @@ +/** + * Mobile post chain, budgeted for a phone GPU: + * + * scene (atmosphere + night-lights blend in the globe shader) + * → [bloom: bright-pass ½ res → separable blur ¼ res ×2] + * → grade (bloom add + lens look + vignette + grain), one full-res pass + * → FXAA (Cesium's, always last) + * + * Cesium's built-in bloom runs a full-resolution multi-pass blur; this one + * touches full resolution exactly once. Without bloom (low quality, thermal + * pressure) the grade runs alone. The 'clear' lens disables both. + */ +import * as Cesium from 'cesium'; + +export const LENSES = Object.freeze([ + 'cinematic', + 'clear', + 'night', + 'thermal', + 'nvg', +]); +const LENS_MODE = { cinematic: 0, clear: 1, night: 2, thermal: 3, nvg: 4 }; + +const GRADE = /* glsl */ ` +uniform sampler2D colorTexture; +#ifdef BLOOM +uniform sampler2D bloomTexture; +uniform float bloomStrength; +#endif +uniform float mode; +uniform float vignette; +uniform float exposure; +uniform float saturation; +uniform float contrast; +uniform float grain; +uniform float seed; +in vec2 v_textureCoordinates; + +vec3 ironbow(float t) { + t = clamp(t, 0.0, 1.0); + vec3 a = mix(vec3(0.0, 0.0, 0.04), vec3(0.20, 0.0, 0.42), smoothstep(0.0, 0.22, t)); + a = mix(a, vec3(0.70, 0.0, 0.48), smoothstep(0.18, 0.42, t)); + a = mix(a, vec3(0.95, 0.25, 0.10), smoothstep(0.38, 0.62, t)); + a = mix(a, vec3(1.0, 0.68, 0.0), smoothstep(0.58, 0.80, t)); + return mix(a, vec3(1.0, 1.0, 0.85), smoothstep(0.78, 1.0, t)); +} + +float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +void main() { + vec2 uv = v_textureCoordinates; + vec4 src = texture(colorTexture, uv); + vec3 c = src.rgb; +#ifdef BLOOM + vec3 glow = texture(bloomTexture, uv).rgb; + c += glow * bloomStrength; +#endif + float l = dot(c, vec3(0.2126, 0.7152, 0.0722)); + if (mode < 0.5) { + // Cinematic: gentle exposure lift, cool shadows / warm highlights, + // a soft shoulder, slightly richer colour. + c *= exposure; + vec3 cool = vec3(0.90, 0.99, 1.12); + vec3 warm = vec3(1.07, 1.0, 0.92); + c *= mix(cool, warm, smoothstep(0.08, 0.75, l)); + c = c / (1.0 + 0.22 * c) * 1.22; + float g = dot(c, vec3(0.2126, 0.7152, 0.0722)); + c = mix(vec3(g), c, saturation); + c = (c - 0.45) * contrast + 0.45; + } else if (mode < 2.5) { + // Night: deep blue-black ground, sodium-warm lights. + float g = l; + vec3 tint = mix(vec3(0.35, 0.50, 0.95), vec3(1.0, 0.82, 0.55), smoothstep(0.15, 0.7, g)); + c = mix(c, g * tint * 1.6, 0.55); + c = (c - 0.04) * 1.12; + } else if (mode < 3.5) { + // Thermal: luminance as temperature through an ironbow ramp. + float t = pow(clamp(l * 1.15, 0.0, 1.0), 0.85); + c = ironbow(t); + } else { + // NVG: P43 phosphor, intensifier gain and scintillation. + float n = hash(uv * vec2(1733.0, 977.0) + seed); + float v = pow(clamp(l * 2.2, 0.0, 1.0), 0.7) + (n - 0.5) * 0.05 * (1.0 - l); + c = vec3(0.30, 1.0, 0.45) * v + vec3(0.0, 0.035, 0.01); + } + vec2 d = (uv - 0.5) * vec2(1.0, 1.1); + float vig = 1.0 - vignette * smoothstep(0.25, 0.85, length(d)); + if (mode > 3.5) vig *= smoothstep(0.78, 0.62, length(d * vec2(0.9, 0.6))); + c *= vig; + c += (hash(uv * 911.0 + seed) - 0.5) * grain; + out_FragColor = vec4(clamp(c, 0.0, 1.0), src.a); +} +`; + +const BRIGHT = /* glsl */ ` +uniform sampler2D colorTexture; +uniform vec2 colorTextureDimensions; +uniform float threshold; +in vec2 v_textureCoordinates; +void main() { + vec2 t = 0.5 / colorTextureDimensions; + vec3 c = texture(colorTexture, v_textureCoordinates + vec2(-t.x, -t.y)).rgb; + c += texture(colorTexture, v_textureCoordinates + vec2(t.x, -t.y)).rgb; + c += texture(colorTexture, v_textureCoordinates + vec2(-t.x, t.y)).rgb; + c += texture(colorTexture, v_textureCoordinates + vec2(t.x, t.y)).rgb; + c *= 0.25; + float l = max(c.r, max(c.g, c.b)); + float k = smoothstep(threshold, threshold + 0.3, l); + out_FragColor = vec4(c * k, 1.0); +} +`; + +const BLUR = /* glsl */ ` +uniform sampler2D colorTexture; +uniform vec2 colorTextureDimensions; +uniform vec2 direction; +in vec2 v_textureCoordinates; +void main() { + vec2 o = direction / colorTextureDimensions; + vec3 c = texture(colorTexture, v_textureCoordinates).rgb * 0.2270270270; + c += texture(colorTexture, v_textureCoordinates + o * 1.3846153846).rgb * 0.3162162162; + c += texture(colorTexture, v_textureCoordinates - o * 1.3846153846).rgb * 0.3162162162; + c += texture(colorTexture, v_textureCoordinates + o * 3.2307692308).rgb * 0.0702702703; + c += texture(colorTexture, v_textureCoordinates - o * 3.2307692308).rgb * 0.0702702703; + out_FragColor = vec4(c, 1.0); +} +`; + +const LOOKS = { + cinematic: { + exposure: 0.98, + saturation: 1.12, + contrast: 1.1, + vignette: 0.38, + grain: 0.012, + bloom: 0.85, + }, + night: { + exposure: 1, + saturation: 1, + contrast: 1, + vignette: 0.45, + grain: 0.015, + bloom: 1.2, + }, + thermal: { + exposure: 1, + saturation: 1, + contrast: 1, + vignette: 0.3, + grain: 0.006, + bloom: 0.5, + }, + nvg: { + exposure: 1, + saturation: 1, + contrast: 1, + vignette: 0.2, + grain: 0.012, + bloom: 0.9, + }, + clear: { + exposure: 1, + saturation: 1, + contrast: 1, + vignette: 0, + grain: 0, + bloom: 0, + }, +}; + +export function createPostChain(scene) { + const params = { + mode: 0, + lens: 'cinematic', + bloomAllowed: true, + seed: 0, + ...LOOKS.cinematic, + }; + const gradeUniforms = { + mode: () => params.mode, + vignette: () => params.vignette, + exposure: () => params.exposure, + saturation: () => params.saturation, + contrast: () => params.contrast, + grain: () => params.grain, + seed: () => params.seed, + }; + + const stages = scene.postProcessStages; + stages.fxaa.enabled = true; + + const grade = stages.add( + new Cesium.PostProcessStage({ + name: 'gev_mobile_grade', + fragmentShader: GRADE, + uniforms: gradeUniforms, + }), + ); + + const generate = new Cesium.PostProcessStageComposite({ + name: 'gev_mobile_bloom_generate', + stages: [ + new Cesium.PostProcessStage({ + name: 'gev_mobile_bloom_bright', + fragmentShader: BRIGHT, + textureScale: 0.5, + sampleMode: Cesium.PostProcessStageSampleMode.LINEAR, + uniforms: { threshold: () => (params.mode === 2 ? 0.35 : 0.72) }, + }), + new Cesium.PostProcessStage({ + name: 'gev_mobile_bloom_blur_x', + fragmentShader: BLUR, + textureScale: 0.25, + sampleMode: Cesium.PostProcessStageSampleMode.LINEAR, + uniforms: { direction: new Cesium.Cartesian2(1.6, 0) }, + }), + new Cesium.PostProcessStage({ + name: 'gev_mobile_bloom_blur_y', + fragmentShader: BLUR, + textureScale: 0.25, + sampleMode: Cesium.PostProcessStageSampleMode.LINEAR, + uniforms: { direction: new Cesium.Cartesian2(0, 1.6) }, + }), + ], + }); + const bloom = stages.add( + new Cesium.PostProcessStageComposite({ + name: 'gev_mobile_bloom', + inputPreviousStageTexture: false, + stages: [ + generate, + new Cesium.PostProcessStage({ + name: 'gev_mobile_grade_bloom', + fragmentShader: `#define BLOOM\n${GRADE}`, + uniforms: { + ...gradeUniforms, + bloomTexture: generate.name, + bloomStrength: () => params.bloom, + }, + }), + ], + }), + ); + + function apply() { + const clear = params.lens === 'clear'; + const useBloom = !clear && params.bloomAllowed && params.bloom > 0; + grade.enabled = !clear && !useBloom; + bloom.enabled = useBloom; + scene.requestRender(); + } + + // Refresh the grain/scintillation seed only on frames that render anyway. + const removeSeed = scene.preRender.addEventListener(() => { + params.seed = (params.seed + 0.6180339887) % 1; + }); + + apply(); + + return { + setLens(lens) { + if (!LENSES.includes(lens)) throw new RangeError(`Unknown lens: ${lens}`); + params.lens = lens; + params.mode = LENS_MODE[lens]; + Object.assign(params, LOOKS[lens]); + apply(); + }, + get lens() { + return params.lens; + }, + setBloomAllowed(on) { + params.bloomAllowed = Boolean(on); + apply(); + }, + get bloomActive() { + return bloom.enabled; + }, + destroy() { + removeSeed(); + stages.remove(grade); + stages.remove(bloom); + }, + }; +} diff --git a/mobile/globe/quality.js b/mobile/globe/quality.js new file mode 100644 index 000000000..4c673b903 --- /dev/null +++ b/mobile/globe/quality.js @@ -0,0 +1,150 @@ +/** + * Adaptive quality governor (DESIGN.md §5). + * + * The scene renders on demand, so idle time is not slow time: only intervals + * between back-to-back frames (< 250 ms apart, i.e. while something animates) + * count. Over a rolling 2 s window, a mean frame time above 20 ms steps the + * pixel ratio down 2 → 1.5 → 1.25 → 1 and, from 1.25 down, switches bloom + * off. Eight calm seconds at vsync (< 17.5 ms) step back up toward the mode's ceiling. + * Battery saver (Battery API) and 'low' pin the floor. + */ + +export const SCALE_STEPS = Object.freeze([1, 1.25, 1.5, 2]); +const WINDOW_MS = 2000; +const UPGRADE_CALM_MS = 8000; +const SLOW_MS = 20; +const FAST_MS = 17.5; // holding vsync at 60 Hz + +export function createGovernor( + viewer, + { + quality = 'auto', + post, + onChange, + devicePixelRatio = globalThis.devicePixelRatio || 1, + } = {}, +) { + const scene = viewer.scene; + const globe = scene.globe; + const dprCap = Math.min(devicePixelRatio, 2); + const ceiling = SCALE_STEPS.filter((s) => s <= dprCap + 1e-6).length - 1; + let mode = quality; + let index = mode === 'low' ? 0 : ceiling; + let lowPower = false; + let samples = []; + let last = 0; + let calmSince = 0; + let fps = 0; + let bloomCut = false; // set when still slow at the floor + + function apply() { + const scale = SCALE_STEPS[index]; + viewer.useBrowserRecommendedResolution = true; // resolutionScale = CSS→device ratio + viewer.resolutionScale = scale; + // Screen-space error is measured in drawing-buffer pixels: scale it with + // the pixel ratio so a 2x phone fetches the tiles a 1x screen would, + // with a compact-screen bias (§5: 2 → 4). + const compact = + Math.min(viewer.canvas.clientWidth, viewer.canvas.clientHeight) < 600; + const base = mode === 'high' ? 1.5 : compact ? 2 : 1.5; + globe.maximumScreenSpaceError = base * Math.max(1, scale); + globe.tileCacheSize = mode === 'low' || lowPower ? 60 : 120; + // The first step down keeps bloom; the second (or being slow at the + // floor) drops it. + post?.setBloomAllowed( + mode !== 'low' && !lowPower && !bloomCut && ceiling - index < 2, + ); + scene.requestRender(); + onChange?.({ + scale, + fps: Math.round(fps), + bloom: Boolean(post?.bloomActive), + quality: mode, + }); + } + + let renderStart = 0; + const removePre = scene.preRender.addEventListener(() => { + renderStart = performance.now(); + }); + const remove = scene.postRender.addEventListener(() => { + const now = performance.now(); + const dt = now - last; + const cost = now - renderStart; + last = now; + // Back-to-back frames measure the real frame time; an isolated frame + // after an idle gap still reports what the render itself cost. + const sample = dt > 0 && dt <= 250 ? dt : cost; + samples.push([now, sample]); + while (samples.length && now - samples[0][0] > WINDOW_MS) samples.shift(); + let slowCount = 0; + for (const s of samples) if (s[1] > 100) slowCount++; + if (samples.length < 20 && slowCount < 3) return; + let sum = 0; + for (const s of samples) sum += s[1]; + const mean = sum / samples.length; + fps = 1000 / mean; + if (mode !== 'auto') return; + if (mean > SLOW_MS && (index > 0 || (!bloomCut && post?.bloomActive))) { + if (index > 0) index--; + else bloomCut = true; + samples = []; + calmSince = now; + apply(); + } else if (mean < FAST_MS) { + if (!calmSince) calmSince = now; + if ( + now - calmSince > UPGRADE_CALM_MS && + (index < ceiling || bloomCut) && + !lowPower + ) { + if (bloomCut) bloomCut = false; + else index++; + samples = []; + calmSince = now; + apply(); + } + } else calmSince = now; + }); + + // Battery saver: drop to the floor while discharging under 20 %. + globalThis.navigator?.getBattery?.().then( + (battery) => { + const check = () => { + const next = !battery.charging && battery.level < 0.2; + if (next === lowPower) return; + lowPower = next; + if (lowPower) index = 0; + apply(); + }; + battery.addEventListener('levelchange', check); + battery.addEventListener('chargingchange', check); + check(); + }, + () => {}, + ); + + apply(); + + return { + setQuality(next) { + mode = next; + index = mode === 'low' ? 0 : ceiling; + bloomCut = false; + apply(); + }, + state() { + return { + scale: SCALE_STEPS[index], + fps: Math.round(fps), + bloom: Boolean(post?.bloomActive), + quality: mode, + lowPower, + }; + }, + destroy() { + remove(); + removePre(); + }, + }; +} diff --git a/mobile/globe/situations.js b/mobile/globe/situations.js new file mode 100644 index 000000000..78fd403d9 --- /dev/null +++ b/mobile/globe/situations.js @@ -0,0 +1,566 @@ +/** + * Situation cards for the Now feed, built from the same live feeds the layers + * use. Every card carries real imagery of its place (keyless Esri export), + * a target-centred camera and the layers worth turning on. A feed that fails + * drops only its own cards: the bundled Bhote Koshi reconstruction and the + * evergreen places always resolve, so the feed is never empty. + */ +import { twoline2satrec } from 'satellite.js'; +import { findNextIssPass } from '../../src/data/issPass.js'; +import { satPosition } from './layers/orbit.js'; +import { AIRPORTS, EVERGREEN, FORT_NOVOSEL } from './places.js'; +import { densestCell, haversineKm, relTime, thumbnailAround } from './geo.js'; + +const DAY = 86_400_000; +const FEED_BUDGET_MS = 9_000; +// OpenSky's global snapshot routinely takes 10-15 s through the proxy. +const FLIGHTS_BUDGET_MS = 16_000; + +const withBudget = (promise, ms = FEED_BUDGET_MS) => + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('feed budget exceeded')), ms), + ), + ]); + +function corridorKm(ev) { + const c = ev?.reconstruction?.corridor; + if (!c?.length) return 22; + return Math.round(Math.abs(c.at(-1).chainageM - c[0].chainageM) / 100) / 10; +} + +function fmtLatLon(lat, lon) { + return `${Math.abs(lat).toFixed(1)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(1)}°${lon >= 0 ? 'E' : 'W'}`; +} + +function card(base) { + const radiusKm = base.radiusKm ?? 60; + const camHeight = base.camera?.height ?? radiusKm * 2500; + return { + id: base.id, + kind: base.kind, + family: base.family, + section: base.section || 'now', + title: base.title, + subtitle: base.subtitle || '', + place: base.place || fmtLatLon(base.lat, base.lon), + lon: base.lon, + lat: base.lat, + camera: { + lon: base.lon, + lat: base.lat, + height: camHeight, + heading: base.camera?.heading ?? 0, + pitch: base.camera?.pitch ?? -50, + ...(Number.isFinite(base.camera?.targetHeight) + ? { targetHeight: base.camera.targetHeight } + : {}), + }, + layers: base.layers || [], + ...(base.time ? { time: base.time } : {}), + metrics: base.metrics || [], + thumbnail: base.thumbnail || thumbnailAround(base.lon, base.lat, radiusKm), + live: base.live ?? true, + updatedAt: base.updatedAt ?? Date.now(), + source: base.source || '', + severity: base.severity ?? 0, + }; +} + +/** Region words for a lat/lon (coarse, for subtitles only). */ +function regionOf(lat, lon) { + if (lat > 35 && lat < 72 && lon > -25 && lon < 45) return 'Europe'; + if (lat > 12 && lat < 42 && lon >= 34 && lon < 63) return 'Middle East'; + if (lat > 15 && lat < 72 && lon > -170 && lon < -50) return 'North America'; + if (lat < 15 && lat > -56 && lon > -90 && lon < -30) return 'South America'; + if (lat > -35 && lat < 37 && lon > -20 && lon < 52) return 'Africa'; + if (lat > -50 && lat < -10 && lon > 110 && lon < 180) return 'Australia'; + if (lat > 5 && lon > 52 && lon < 150) return 'Asia'; + if (lat < 5 && lon > 90 && lon < 150) return 'Maritime Southeast Asia'; + return 'Open ocean'; +} + +/** Tropical cyclone basin for a position (for storms without an NHC basin). */ +function basinOf(lat, lon) { + if (lat >= 0) { + if (lon >= 100 || lon < -180) return 'Western Pacific'; + if (lon >= 40 && lon < 100) return 'North Indian Ocean'; + if (lon >= -140 && lon < -80) return 'Eastern Pacific'; + if (lon < -140) return 'Central Pacific'; + return 'Atlantic'; + } + if (lon >= 20 && lon < 135) return 'South Indian Ocean'; + if (lon >= 135 || lon < -120) return 'South Pacific'; + return 'South Atlantic'; +} + +export async function buildSituations(engine, { observer } = {}) { + const { feeds } = engine; + const now = Date.now(); + const get = (feed, opts, ms) => withBudget(feed.get(opts), ms); + const [ + quakes, + cyclones, + fires, + military, + flights, + stations, + launches, + flood, + ] = await Promise.allSettled([ + get(feeds.quakes), + get(feeds.cyclones), + get(feeds.fires), + get(feeds.military, { maxAgeMs: 60_000 }), + get(feeds.flights, { maxAgeMs: 60_000 }, FLIGHTS_BUDGET_MS), + get(feeds.stations), + get(feeds.launches), + get(feeds.flood), + ]); + const ok = (r) => (r.status === 'fulfilled' ? r.value : null); + const out = []; + const tryAdd = (fn) => { + try { + const c = fn(); + if (Array.isArray(c)) out.push(...c); + else if (c) out.push(c); + } catch (error) { + console.warn('[globe:situations]', error); + } + }; + + // Largest quake in the past 24 h. + tryAdd(() => { + const d = ok(quakes); + if (!d) return null; + const day = d.records.filter((q) => q.time && now - q.time < DAY); + if (!day.length) return null; + const q = day.reduce((a, b) => (b.mag > a.mag ? b : a)); + return card({ + id: `quake-${q.stableId}`, + kind: 'earthquake', + family: 'Hazards', + title: `M${q.mag.toFixed(1)} earthquake`, + subtitle: `Largest in 24 h · ${relTime(q.time, now)}`, + place: q.place || fmtLatLon(q.lat, q.lon), + lon: q.lon, + lat: q.lat, + radiusKm: 120, + camera: { height: 450_000, heading: 0, pitch: -65 }, + layers: ['earthquakes'], + time: { start: q.time, end: now }, + metrics: [ + { label: 'Magnitude', value: Number(q.mag.toFixed(1)), unit: 'M' }, + { label: 'Depth', value: Math.round(q.depthKm ?? 0), unit: 'km' }, + { label: 'M2.5+ in 24 h', value: day.length, unit: '' }, + ], + updatedAt: feeds.quakes.updatedAt, + source: 'USGS', + severity: q.mag, + }); + }); + + // Active tropical cyclones. + tryAdd(() => { + const d = ok(cyclones); + if (!d) return null; + return d.records.slice(0, 4).map((s) => { + const label = + s.label || + { + HU: 'Hurricane', + TS: 'Tropical storm', + TD: 'Tropical depression', + TY: 'Typhoon', + TC: 'Cyclone', + }[s.classification] || + 'Storm'; + const kmh = s.windKt ? Math.round(s.windKt * 1.852) : null; + const lastTau = s.forecast?.at(-1)?.tauHours ?? 0; + return card({ + id: `cyclone-${s.id}`, + kind: 'cyclone', + family: 'Hazards', + title: `${label} ${s.name}`, + subtitle: [ + kmh ? `${kmh} km/h` : null, + s.pressureHpa ? `${s.pressureHpa} hPa` : null, + s.forecast?.length ? `${lastTau} h forecast track` : null, + ] + .filter(Boolean) + .join(' · '), + place: `${{ EP: 'Eastern Pacific', AL: 'Atlantic', CP: 'Central Pacific' }[s.basin] || basinOf(s.lat, s.lon)} basin`, + lon: s.lon, + lat: s.lat, + radiusKm: 700, + camera: { height: 2_600_000, heading: 0, pitch: -75 }, + layers: ['cyclones'], + time: { start: s.t, end: s.t + lastTau * 3_600_000 }, + metrics: [ + ...(kmh ? [{ label: 'Wind', value: kmh, unit: 'km/h' }] : []), + ...(s.pressureHpa + ? [{ label: 'Pressure', value: s.pressureHpa, unit: 'hPa' }] + : []), + ...(s.movement?.speedKt + ? [ + { + label: 'Moving', + value: Math.round(s.movement.speedKt * 1.852), + unit: 'km/h', + }, + ] + : []), + ], + updatedAt: feeds.cyclones.updatedAt, + source: s.source, + severity: 5 + (s.windKt || 0) / 40, + }); + }); + }); + + // Fire cluster: Europe first if it has a real cluster, else the densest. + tryAdd(() => { + const d = ok(fires); + if (!d?.records.length) return null; + const hotspots = d.mode === 'hotspots'; + const recent = hotspots + ? d.records.filter((f) => !f.t || now - f.t < DAY) + : d.records; + const cell = hotspots ? 1 : 4; + const europe = densestCell( + recent.filter((f) => regionOf(f.lat, f.lon) === 'Europe'), + cell, + ); + const best = + europe && europe.count >= (hotspots ? 20 : 2) + ? europe + : densestCell(recent, cell); + if (!best) return null; + const region = regionOf(best.lat, best.lon); + const sample = best.items.find( + (f) => f.title && f.title !== 'Fire hotspot', + ); + return card({ + id: `fires-${best.lat.toFixed(1)}-${best.lon.toFixed(1)}`, + kind: 'fires', + family: 'Hazards', + title: `Fire cluster · ${region}`, + subtitle: hotspots + ? `${best.count} hotspots in 24 h` + : sample + ? sample.title + : `${best.count} active wildfire events`, + place: sample?.title?.split(',').slice(1).join(',').trim() || region, + lon: best.lon, + lat: best.lat, + radiusKm: hotspots ? 90 : 220, + camera: { height: hotspots ? 260_000 : 700_000, heading: 0, pitch: -60 }, + layers: ['fires'], + metrics: [ + { + label: hotspots ? 'Hotspots' : 'Events', + value: best.count, + unit: '', + }, + { label: 'Worldwide', value: recent.length, unit: '' }, + ], + updatedAt: feeds.fires.updatedAt, + source: d.source, + severity: 3 + Math.min(2, best.count / 50), + }); + }); + + // Military air activity hotspot. + tryAdd(() => { + const d = ok(military); + if (!d?.records.length) return null; + const airborne = d.records.filter((r) => !r.onGround); + const best = densestCell(airborne, 6); + if (!best || best.count < 2) return null; + const types = [ + ...new Set(best.items.map((r) => r.type).filter(Boolean)), + ].slice(0, 3); + return card({ + id: `military-${Math.round(best.lat)}-${Math.round(best.lon)}`, + kind: 'military', + family: 'Sky', + title: `Military air activity · ${regionOf(best.lat, best.lon)}`, + subtitle: `${best.count} military aircraft${types.length ? ` · ${types.join(', ')}` : ''}`, + lon: best.lon, + lat: best.lat, + radiusKm: 260, + camera: { height: 900_000, heading: 0, pitch: -70 }, + layers: ['military'], + metrics: [ + { label: 'In area', value: best.count, unit: '' }, + { label: 'Tracked worldwide', value: airborne.length, unit: '' }, + ], + updatedAt: feeds.military.updatedAt, + source: 'adsb.lol', + severity: 2.5, + }); + }); + + // ISS now (+ next pass when we know where the viewer is). + tryAdd(() => { + const d = ok(stations); + const iss = d?.entries.find((e) => e.line1.slice(2, 7).trim() === '25544'); + if (!iss) return null; + const satrec = twoline2satrec(iss.line1, iss.line2); + const p = satPosition(satrec, now); + if (!p) return null; + const metrics = [ + { label: 'Altitude', value: Math.round(p.altM / 1000), unit: 'km' }, + { + label: 'Speed', + value: Math.round((p.speedMps * 3.6) / 100) * 100, + unit: 'km/h', + }, + ]; + let subtitle = `Over ${regionOf(p.lat, p.lon)} · ${fmtLatLon(p.lat, p.lon)}`; + if ( + observer && + Number.isFinite(observer.lat) && + Number.isFinite(observer.lon) + ) { + const pass = findNextIssPass({ + satrec, + latDeg: observer.lat, + lonDeg: observer.lon, + fromMs: now, + horizonHours: 24, + }); + if (pass) { + subtitle = `Next pass over you ${relTime(pass.riseMs, now)} · max ${Math.round(pass.maxElevDeg)}°`; + metrics.push({ + label: 'Next pass', + value: new Date(pass.riseMs).toISOString(), + unit: 'utc', + }); + } + } + return card({ + id: 'iss-now', + kind: 'iss', + family: 'Orbit', + title: 'ISS now', + subtitle, + place: regionOf(p.lat, p.lon), + lon: p.lon, + lat: p.lat, + radiusKm: 900, + camera: { height: 5_500_000, heading: 0, pitch: -90 }, + layers: ['iss', 'satellites'], + metrics, + updatedAt: now, + source: 'CelesTrak TLE · SGP4', + severity: 2, + }); + }); + + // Busiest airport airspace now, and the Fort Novosel helicopter school. + tryAdd(() => { + const d = ok(flights) || { records: [], source: 'OpenSky Network' }; + const flightsLive = d.records.length > 0; + const cards = []; + let best = null; + for (const ap of AIRPORTS) { + let n = 0; + for (const r of d.records) { + if (Math.abs(r.lat - ap.lat) > 0.6 || Math.abs(r.lon - ap.lon) > 0.9) + continue; + if (r.altM > 4000) continue; + if (haversineKm(ap.lat, ap.lon, r.lat, r.lon) <= 50) n++; + } + if (!best || n > best.n) best = { ...ap, n }; + } + if (flightsLive && best && best.n > 0) + cards.push( + card({ + id: `airport-${best.code}`, + kind: 'airport', + family: 'Sky', + title: `Busiest airspace · ${best.code}`, + subtitle: `${best.n} aircraft below 4 km within 50 km of ${best.name}`, + place: best.name, + lon: best.lon, + lat: best.lat, + radiusKm: 40, + camera: { height: 60_000, heading: 20, pitch: -45 }, + layers: ['flights', 'ga', 'helicopters'], + metrics: [ + { label: 'Low aircraft', value: best.n, unit: '' }, + { + label: 'Airborne worldwide', + value: d.records.filter((r) => !r.onGround).length, + unit: '', + }, + ], + updatedAt: feeds.flights.updatedAt, + source: d.source, + severity: 1.5, + }), + ); + const near = d.records.filter( + (r) => + haversineKm(FORT_NOVOSEL.lat, FORT_NOVOSEL.lon, r.lat, r.lon) <= + FORT_NOVOSEL.radiusKm, + ); + const mil = ok(military)?.records || []; + const helis = [ + ...near.filter((r) => r.helicopter), + ...mil.filter( + (r) => + r.helicopter && + haversineKm(FORT_NOVOSEL.lat, FORT_NOVOSEL.lon, r.lat, r.lon) <= + FORT_NOVOSEL.radiusKm, + ), + ]; + const lowSlow = near.filter( + (r) => + !r.onGround && r.altM < 900 && (r.speed ?? 99) < 60 && !r.helicopter, + ); + cards.push( + card({ + id: 'fort-novosel', + kind: 'helicopters', + family: 'Sky', + section: 'always', + title: 'Helicopter school circuits', + subtitle: !flightsLive + ? 'US Army Aviation Center of Excellence · live traffic unavailable' + : helis.length || lowSlow.length + ? `${helis.length} rotorcraft · ${lowSlow.length} low & slow contacts over Fort Novosel` + : 'Quiet right now · US Army Aviation Center of Excellence', + place: 'Fort Novosel, Alabama', + lon: FORT_NOVOSEL.lon, + lat: FORT_NOVOSEL.lat, + radiusKm: 35, + camera: { height: 45_000, heading: 340, pitch: -45 }, + layers: ['helicopters', 'military', 'ga'], + metrics: [ + { label: 'Rotorcraft', value: helis.length, unit: '' }, + { label: 'Low & slow', value: lowSlow.length, unit: '' }, + { label: 'All aircraft', value: near.length, unit: '' }, + ], + updatedAt: feeds.flights.updatedAt || now, + source: d.source, + live: flightsLive, + severity: helis.length ? 1.2 : 0.2, + }), + ); + return cards; + }); + + // Next launch. + tryAdd(() => { + const d = ok(launches); + const next = d?.records.find((r) => r.net > now - 3600_000); + if (!next) return null; + return card({ + id: `launch-${next.id}`, + kind: 'launch', + family: 'Orbit', + title: next.name, + subtitle: `${next.provider || 'Launch'} · T−${relTime(next.net, now).replace('in ', '')}`, + place: next.location || next.pad, + lon: next.lon, + lat: next.lat, + radiusKm: 25, + camera: { height: 30_000, heading: 0, pitch: -40 }, + layers: ['launches', 'iss'], + time: { start: next.net - 3600_000, end: next.net + 3600_000 }, + metrics: [ + { label: 'NET', value: new Date(next.net).toISOString(), unit: 'utc' }, + ...(next.orbit + ? [{ label: 'Orbit', value: next.orbit, unit: '' }] + : []), + ...(next.status + ? [{ label: 'Status', value: next.status, unit: '' }] + : []), + ], + updatedAt: feeds.launches.updatedAt, + source: d.source, + severity: next.net - now < 2 * DAY ? 3 : 1, + live: d.upcomingOk, + }); + }); + + // Bundled reconstruction: always present. + tryAdd(() => { + const ev = ok(flood)?.event; + const vp = ev?.viewpoints?.corridor; + const lon = vp?.lon ?? 85.3548; + const lat = vp?.lat ?? 28.207; + const observed = ev?.observedDate || '2026-08-26'; + return card({ + id: 'bhote-koshi-2026', + kind: 'reconstruction', + family: 'Hazards', + section: 'reconstruction', + title: ev?.title || 'Bhote Koshi Outburst Flood', + subtitle: `Reconstruction · ${ev?.evidenceSpine?.length ?? 0} evidence beats · 2021 vs 2026 imagery`, + place: 'Rasuwa, Nepal', + lon, + lat, + radiusKm: 9, + camera: { + height: vp + ? Math.round( + (vp.rangeM || 8600) * + Math.sin(Math.abs(vp.pitchDeg || 39) * (Math.PI / 180)), + ) + : 5400, + heading: vp?.headingDeg ?? 344, + pitch: vp?.pitchDeg ?? -39, + targetHeight: vp?.targetElevationM ?? 1536, + }, + layers: ['floods'], + time: { + start: Date.parse(`${observed}T00:00:00Z`), + end: Date.parse(`${observed}T12:00:00Z`), + at: Date.parse(`${observed}T05:30:00Z`), + }, + metrics: [ + { label: 'Corridor', value: corridorKm(ev), unit: 'km' }, + { + label: 'Witness anchors', + value: ev?.witnessAnchors?.length ?? 0, + unit: '', + }, + { + label: 'Field reports', + value: ev?.fieldReports?.length ?? 0, + unit: '', + }, + ], + updatedAt: Date.parse(observed), + live: false, + source: 'GeoPera · Vantor Open Data · bundled event pack', + severity: 4, + }); + }); + + // Evergreen places. + for (const e of EVERGREEN) { + tryAdd(() => + card({ + ...e, + id: `always-${e.id}`, + kind: 'place', + section: 'always', + live: false, + updatedAt: now, + source: 'Bundled place', + severity: 0, + }), + ); + } + + const live = out + .filter((c) => c.section === 'now') + .sort((a, b) => b.severity - a.severity); + const rest = out.filter((c) => c.section !== 'now'); + return [...live, ...rest]; +}