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];
+}