mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
19 files changed
+5816
No files matched your search
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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 })];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in new issue
Block a user