feat(mobile): add the phone-tuned Cesium globe engine

createGlobe() implements the Globe API in mobile/DESIGN.md: Esri day
imagery blended with NASA Black Marble night lights across the
terminator, Google 3D tiles below 25 km when keyed, a single-pass
grade with half-res bloom and lenses, an adaptive quality governor,
live layers on the repo's /api routes and parsers, time scrubbing,
picking, AR contact queries and live situation cards.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-18 05:27:33 +00:00
1 parent 35ffcedcad
commit a8b7680c7a
19 files changed
+5816

No files matched your search

+126
View File
@@ -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);
};
}
+45
View File
@@ -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;
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#05070B" />
<link rel="icon" href="data:," />
<title>Globe engine · dev</title>
<link rel="stylesheet" href="./dev.css" />
</head>
<body>
<div id="globe"></div>
<div id="labels"></div>
<div id="hud" aria-live="polite"></div>
<script type="module" src="./dev.js"></script>
</body>
</html>
+61
View File
@@ -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);
}
+687
View File
@@ -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 },
);
}
+116
View File
@@ -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,
};
}
+253
View File
@@ -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;
}
+215
View File
@@ -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: '<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a> — 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: <a href="https://earthdata.nasa.gov/gibs" target="_blank" rel="noopener">NASA GIBS</a> VIIRS Black Marble',
},
]);
const TERRAIN_CREDIT = {
id: 'terrain',
text: 'Terrain: Re:Earth / Mapterhorn (CC BY 4.0)',
html: 'Terrain: <a href="https://reearth.io" target="_blank" rel="noopener">Re:Earth</a> / 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);
},
};
}
+915
View File
@@ -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;
}
+287
View File
@@ -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,
},
),
];
}
+384
View File
@@ -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
}
+95
View File
@@ -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;
}
+622
View File
@@ -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 },
};
}
}
+435
View File
@@ -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 },
),
];
}
+262
View File
@@ -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 })];
}
+288
View File
@@ -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,
});
+289
View File
@@ -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);
},
};
}
+150
View File
@@ -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();
},
};
}
+566
View File
@@ -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];
}