mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
wip(mobile): checkpoint the globe engine and vision mockup in progress
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
36 files changed
+10296
-623
No files matched your search
@@ -0,0 +1,103 @@
|
||||
# Globe API: round-two additions
|
||||
|
||||
These additions extend DESIGN.md §10 and change no existing signature. Import them from `mobile/globe/index.js`. The doc comment at the top of that file repeats this contract.
|
||||
|
||||
## Viewer position
|
||||
|
||||
```js
|
||||
globe.setViewer({ lon, lat } | null) // where the person is: sets distanceKm and the ISS/Starlink "over you" cards
|
||||
globe.viewer() → { lon, lat } | null
|
||||
```
|
||||
|
||||
## Situations (extended)
|
||||
|
||||
```js
|
||||
globe.situations({ observer? }) → Promise<Situation[]>
|
||||
globe.omitted() → [{ genre, reason }] // genres the last call left out, and why
|
||||
globe.timings() → { [genre]: ms } // per-genre build time of the last call
|
||||
```
|
||||
|
||||
`Situation` keeps every field from round one (`id, kind, family, section, title, subtitle, place, lon, lat, camera, layers, time?, metrics, thumbnail, live, updatedAt, source, severity`) and adds these:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `band` | `'orbit' \| 'sky' \| 'ground' \| 'inside'` | The altitude band of the sensor. The engine never emits `inside`, because only the shell's own witness and room cards are inside. |
|
||||
| `archetype` | `'sensor' \| 'eye' \| 'track' \| 'number'` | The card layout. The shell adds `witness` and `room` cards itself. |
|
||||
| `telemetry` | `string` | A short uppercase strip, for example `SKY · FL206 · 332 KT · SQ 7700 · EZY58XM`, `GROUND · CAM 07450 · PICCADILLY CIRCUS · 02:16:14Z` or `ORBIT · ISS · 433 KM · 7.65 KM/S`. Draw it in HTML in a monospace face. |
|
||||
| `media?` | `{ kind:'image', url, refreshMs, cameraId, headingDeg?, provider?, city? }` | Eye cards only. `url` is a same-origin frame from `/api/cctv/frame/…` that was checked to be a real upstream image, not the proxy's synthetic fallback. For a fresh frame, call `globe.eyeFrameUrl(cameraId)` every `refreshMs`. |
|
||||
| `series?` | `[{ t, v }]` | Real history for number cards, and for some sensor cards. `t` is epoch ms. |
|
||||
| `seriesLabel?` | `string` | The quantity `v` measures, for example `'lightning-active area (km²) per 15 min'`. |
|
||||
| `headline?` | `{ value, unit, label }` | The one big number on a track or number card. |
|
||||
| `distanceKm?` | `number` | The great-circle distance from the viewer. It is present only once `setViewer` has been called, or when `observer` is passed. |
|
||||
|
||||
The `thumbnail` field is still a plain Esri still, so older code keeps working. New code calls `globe.thumbnail(situation)` instead.
|
||||
|
||||
The engine orders the cards by severity, then interleaves them so neighbouring cards differ in band and archetype where possible.
|
||||
|
||||
## Card compositor
|
||||
|
||||
```js
|
||||
globe.thumbnail(situation, { width = 360, height = 480, dpr = 2 } = {}) → Promise<string /* blob: object URL */>
|
||||
```
|
||||
|
||||
The compositor builds each thumbnail as a JPEG in an offscreen 2D canvas, never in the Cesium viewer. It works in three layers:
|
||||
|
||||
1. **Base imagery.** Esri World Imagery tiles, which are CORS-clean, or NASA GIBS Black Marble for orbit cards.
|
||||
2. **Real overlays.** The data that describes the situation. Examples include NOAA IR with a cold-top enhancement, MRMS radar, lightning density dots by age, the NHC cone with the forecast track and 34/50/64-kt wind radii, aircraft with 2-minute velocity leaders, quake rings, fires with a smoke plume along the observed wind, and SGP4 ground tracks.
|
||||
3. **Grade.** A tint for the altitude band (orbit violet night, sky cool, ground amber) and a vignette.
|
||||
|
||||
The compositor draws no text and no brackets.
|
||||
|
||||
Eye cards return the camera frame itself, lightly graded. Results are cached in memory and, when the page is a secure context, in the Cache API, keyed by id, size and an update bucket. Eye cards refresh every 30 s; other cards follow `updatedAt` in 5-minute buckets. Tiles are shared across cards, and at most 3 cards render at once.
|
||||
|
||||
If an overlay fails, the card still gets its base picture. The promise rejects only when the base imagery or the camera frame fails. In that case, fall back to `situation.thumbnail` or to the shell's own placeholder.
|
||||
|
||||
## Pins (discourse and capture)
|
||||
|
||||
```js
|
||||
globe.addPin({ id, lon, lat, kind, headingDeg?, fovDeg = 60, rangeM = 350, color?, label?, altM? }) → id
|
||||
// kind: 'post' | 'witness' | 'eye' | 'live' | 'hologram'
|
||||
// 'live' and 'eye' pins draw a view-frustum wedge on the ground along headingDeg
|
||||
globe.removePin(id) → boolean
|
||||
globe.pins() → [{ id, lon, lat, kind, headingDeg, fovDeg, rangeM, color, label, altM }]
|
||||
```
|
||||
|
||||
- Picking a pin emits `'pick'` with `{ kind: 'pin', pinKind, id, title, subtitle, lon, lat, meta: { layer: 'pins', headingDeg, fovDeg, label } }`.
|
||||
- Pins appear in `globe.labels()` with `layer: 'pins'` and `pinKind`.
|
||||
- Live pins breathe at 1 Hz.
|
||||
|
||||
## Labels
|
||||
|
||||
Each entry from `globe.labels(max)` can now carry an optional `detail`, a short intensity tag:
|
||||
|
||||
- Cyclones: `CAT 2 · 85 KT · 974 HPA`
|
||||
- Emergency squawks: `SQ 7700`
|
||||
- Air quality: `AQI 152`
|
||||
|
||||
## Nearest public camera
|
||||
|
||||
```js
|
||||
globe.nearestEye(lon, lat, km = 50)
|
||||
→ Promise<{ id, title, lon, lat, distanceKm, headingDeg, fovDeg, imageUrl, city, provider } | null>
|
||||
globe.eyeFrameUrl(cameraId) → string | null // a fresh same-origin frame URL
|
||||
```
|
||||
|
||||
`nearestEye` reads the CCTV catalog at `/api/cctv/sources`, which serves about 4,000 cameras across London, Toronto, Sydney, Seattle, the Bay Area, Los Angeles, Helsinki/Finland, Tallinn, Barcelona, Calgary, Norway and other places. It does not check the frame. A camera can still answer with the proxy's synthetic image when its upstream is down.
|
||||
|
||||
## Weather and storm layers (now available in `globe.layers()`)
|
||||
|
||||
| id | Family | Source | Time |
|
||||
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
|
||||
| `radar` | Weather | NOAA MRMS base reflectivity through nowCOAST (`/api/weather`), CONUS | Scrubbing replays about 50 min of 4-minute frames |
|
||||
| `clouds` | Weather | NOAA global longwave IR mosaic through nowCOAST, drawn as an unlit cloud layer (the alpha-keyed IR) | 6 h of hourly frames |
|
||||
| `lightning` | Weather | NOAA nowCOAST lightning density, 15 min (Americas and Pacific), with a flickering glow per active 0.5° cell | 3 h of 15-minute frames |
|
||||
| `wind` | Weather | 10 m wind, drawn with the desktop app's GPU streamline renderer. Uses `/api/wind` GFS when the server can decode GRIB, otherwise a 12° Open-Meteo grid | Live |
|
||||
| `jet` | Weather | Jet stream at 250 hPa from Open-Meteo, same renderer | Live |
|
||||
| `cyclones` | Hazards | Now also draws the filled NHC cone and 34/50/64-kt wind radii, parsed from the NHC TCM text at api.weather.gov | Forecast scrubbing, as before |
|
||||
| `air-quality` | Hazards | Open-Meteo Air Quality (CAMS model), PM2.5 AQI for 40 major cities | Live |
|
||||
| `gnss` | Infra | `/api/gnss-integrity` (adsb.lol NIC/NACp), shown as 0.5° cells in gpsjam.org bands. From orbit it samples the Baltic, Black Sea, Levant, Gulf and Gulf of Finland; close in, it samples the view | Live |
|
||||
| `transit` | Ground | `/api/transit` GTFS-realtime vehicles from the open feed nearest the view | Live, 20 s |
|
||||
|
||||
Rasters are drawn a few kilometres above the ground and unlit, so storms stay visible on the night side. When an upstream is down, the layer reports `status: 'error'` with a `message`.
|
||||
|
||||
When a weather layer is on, `layers()` entries can also carry `frames` (the number of frames available) and a `message` that gives the product title and the frame age.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #05070b;
|
||||
color: #dfe6f0;
|
||||
font:
|
||||
12px/1.35 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
#globe {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
#status {
|
||||
padding: 10px 14px;
|
||||
font:
|
||||
11px ui-monospace,
|
||||
monospace;
|
||||
color: #8aa0b8;
|
||||
}
|
||||
#sheet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, var(--cw, 216px));
|
||||
gap: 10px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
width: var(--cw, 216px);
|
||||
}
|
||||
figure img {
|
||||
display: block;
|
||||
width: var(--cw, 216px);
|
||||
height: var(--ch, 288px);
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
}
|
||||
figure .tm {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 52px;
|
||||
font:
|
||||
600 8.5px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
}
|
||||
figcaption {
|
||||
padding: 4px 2px 0;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
}
|
||||
figcaption b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
figcaption small {
|
||||
color: #8aa0b8;
|
||||
}
|
||||
.err {
|
||||
color: #ff6a3d;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Globe cards · contact sheet</title>
|
||||
<link rel="stylesheet" href="./contact.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="globe"></div>
|
||||
<header id="status">building situations…</header>
|
||||
<main id="sheet"></main>
|
||||
<script type="module" src="./contact.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
// Contact sheet: every live situation with its composited thumbnail.
|
||||
// Open /mobile/globe/contact.html (optionally ?lat=&lon= for a viewer).
|
||||
import { createGlobe } from './index.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const status = document.getElementById('status');
|
||||
const sheet = document.getElementById('sheet');
|
||||
const globe = await createGlobe(document.getElementById('globe'), {
|
||||
layers: [],
|
||||
terrain: false,
|
||||
});
|
||||
window.globe = globe;
|
||||
if (params.has('lat'))
|
||||
globe.setViewer({
|
||||
lat: Number(params.get('lat')),
|
||||
lon: Number(params.get('lon')),
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
const only = params.get('only');
|
||||
const all = await globe.situations();
|
||||
const list = only
|
||||
? all.filter((s) => only.split(',').some((o) => s.id.startsWith(o)))
|
||||
: all;
|
||||
const cw = Number(params.get('w') || 216);
|
||||
const ch = Math.round((cw * 4) / 3);
|
||||
document.documentElement.style.setProperty('--cw', `${cw}px`);
|
||||
document.documentElement.style.setProperty('--ch', `${ch}px`);
|
||||
window.situations = list;
|
||||
status.textContent = `${list.length} situations in ${Math.round(performance.now() - t0)} ms · rendering thumbnails…`;
|
||||
const t1 = performance.now();
|
||||
let done = 0;
|
||||
await Promise.all(
|
||||
list.map(async (s) => {
|
||||
const fig = document.createElement('figure');
|
||||
const img = document.createElement('img');
|
||||
const tm = document.createElement('div');
|
||||
tm.className = 'tm';
|
||||
tm.textContent = s.telemetry || '';
|
||||
const cap = document.createElement('figcaption');
|
||||
cap.innerHTML = `<b></b><small></small>`;
|
||||
cap.querySelector('b').textContent = s.title;
|
||||
cap.querySelector('small').textContent =
|
||||
`${s.band}/${s.archetype} · ${s.subtitle}`;
|
||||
fig.append(img, tm, cap);
|
||||
sheet.append(fig);
|
||||
try {
|
||||
img.src = await globe.thumbnail(s, { width: cw, height: ch, dpr: 2 });
|
||||
} catch (error) {
|
||||
cap.querySelector('small').className = 'err';
|
||||
cap.querySelector('small').textContent =
|
||||
`thumbnail failed: ${error.message}`;
|
||||
}
|
||||
done++;
|
||||
}),
|
||||
);
|
||||
status.textContent = `${list.length} situations · thumbnails in ${Math.round(performance.now() - t1)} ms · omitted: ${(globe.omitted?.() || []).map((o) => `${o.genre} (${o.reason})`).join('; ')}`;
|
||||
console.log('timings', JSON.stringify(window.globe.timings?.()));
|
||||
document.body.dataset.ready = '1';
|
||||
+1
-1
@@ -48,7 +48,7 @@ const drawLabels = () => {
|
||||
labelLayer.replaceChildren(
|
||||
...globe.labels(40).map((l) => {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = l.title;
|
||||
el.textContent = l.detail ? `${l.title} · ${l.detail}` : l.title;
|
||||
el.style.left = `${l.x}px`;
|
||||
el.style.top = `${l.y}px`;
|
||||
return el;
|
||||
|
||||
@@ -89,7 +89,7 @@ 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) {
|
||||
export function flightFromState(s) {
|
||||
const lon = finite(s[5]);
|
||||
const lat = finite(s[6]);
|
||||
if (lon === null || lat === null) return null;
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
/**
|
||||
* Round-two feeds: weather rasters (NOAA nowCOAST through the app's
|
||||
* /api/weather proxy), wind fields (/api/wind GFS, else Open-Meteo), the jet
|
||||
* stream (Open-Meteo 250 hPa), NHC wind radii (api.weather.gov TCM text,
|
||||
* CORS-enabled), GNSS integrity (/api/gnss-integrity), GTFS-realtime transit
|
||||
* (/api/transit), air quality (Open-Meteo) and regional ADS-B scans.
|
||||
*
|
||||
* Every loader is keyless. Each returns real data or throws; nothing here
|
||||
* invents a value when an upstream is down.
|
||||
*/
|
||||
import { createFeed, FeedError } from './feeds.js';
|
||||
import { finite } from './geo.js';
|
||||
|
||||
const TIMEOUT_MS = 25_000;
|
||||
|
||||
async function getJson(url, { timeoutMs = TIMEOUT_MS, ...init } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!response.ok)
|
||||
throw new FeedError(`HTTP ${response.status} for ${url.split('?')[0]}`, {
|
||||
status: response.status,
|
||||
});
|
||||
return await response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** A small keyed cache of single-flight loads (per anchor / per feed id). */
|
||||
export function createKeyedFeed(name, load, { ttlMs = 60_000, max = 32 } = {}) {
|
||||
const entries = new Map();
|
||||
return {
|
||||
name,
|
||||
peek(key) {
|
||||
return entries.get(key)?.data ?? null;
|
||||
},
|
||||
entries() {
|
||||
return [...entries.values()].filter((e) => e.data);
|
||||
},
|
||||
async get(key, args, { maxAgeMs = ttlMs } = {}) {
|
||||
let e = entries.get(key);
|
||||
if (e?.data && Date.now() - e.at < maxAgeMs) return e.data;
|
||||
if (e?.error && Date.now() - e.failedAt < 30_000) {
|
||||
if (e.data) return e.data;
|
||||
throw e.error;
|
||||
}
|
||||
if (!e) {
|
||||
e = { key, data: null, at: 0, inflight: null, error: null };
|
||||
entries.set(key, e);
|
||||
while (entries.size > max) entries.delete(entries.keys().next().value);
|
||||
}
|
||||
if (!e.inflight)
|
||||
e.inflight = (async () => {
|
||||
try {
|
||||
e.data = await load(args ?? key);
|
||||
e.at = Date.now();
|
||||
e.error = null;
|
||||
} catch (error) {
|
||||
e.error = error;
|
||||
e.failedAt = Date.now();
|
||||
} finally {
|
||||
e.inflight = null;
|
||||
}
|
||||
})();
|
||||
await e.inflight;
|
||||
if (!e.data) throw e.error || new FeedError(`${name} unavailable`);
|
||||
return e.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── Weather rasters ───────────────────────── */
|
||||
|
||||
export const WEATHER_TILE_LEVEL_MAX = 6;
|
||||
|
||||
/** nowCOAST manifest: { product, times[], latest, bounds, title, attribution }. */
|
||||
export function createWeatherManifestFeed(product) {
|
||||
return createFeed(
|
||||
`weather:${product}`,
|
||||
async () => {
|
||||
const m = await getJson(`/api/weather/manifest?product=${product}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (m?.unavailable || !Array.isArray(m?.times) || !m.times.length)
|
||||
throw new FeedError(m?.reason || `${product} imagery unavailable`);
|
||||
return {
|
||||
data: {
|
||||
product,
|
||||
times: m.times,
|
||||
latest: m.latest,
|
||||
bounds: m.bounds,
|
||||
title: m.title,
|
||||
coverage: m.coverage,
|
||||
description: m.description,
|
||||
attribution: m.attribution,
|
||||
source: `NOAA nowCOAST · ${m.title}`,
|
||||
},
|
||||
stale: Boolean(m.stale),
|
||||
};
|
||||
},
|
||||
{ ttlMs: product === 'clouds' ? 10 * 60_000 : 2 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Same-origin whole-extent image URL for a frame (2:1 sizes only). */
|
||||
export function weatherImageUrl(product, time, width = 2048) {
|
||||
const size = width === 4096 ? '' : `&size=${width}x${width / 2}`;
|
||||
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${size}`;
|
||||
}
|
||||
|
||||
/** Same-origin geographic tile URL (z ≤ 6; 2×1 tiles at level 0). */
|
||||
export function weatherTileUrl(product, time, z, x, y) {
|
||||
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z=${z}&x=${x}&y=${y}`;
|
||||
}
|
||||
|
||||
/** Latest frame at or before `ms` (the latest when ms is null). */
|
||||
export function frameAt(times, ms) {
|
||||
if (!times?.length) return null;
|
||||
if (ms == null) return times.at(-1);
|
||||
let best = null;
|
||||
for (const t of times) if (Date.parse(t) <= ms + 60_000) best = t;
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Wind ───────────────────────── */
|
||||
|
||||
const MS_PER_KMH = 1 / 3.6;
|
||||
|
||||
/** u/v (m/s) from a meteorological speed (km/h) and from-direction (deg). */
|
||||
function uv(speedKmh, dirDeg) {
|
||||
const s = speedKmh * MS_PER_KMH;
|
||||
const r = (dirDeg * Math.PI) / 180;
|
||||
return [-s * Math.sin(r), -s * Math.cos(r)];
|
||||
}
|
||||
|
||||
/** A coarse global grid of Open-Meteo points, north to south, 12° spacing
|
||||
* (390 points: one request stays under Open-Meteo's per-minute budget). */
|
||||
function omGrid(step = 12, north = 72, south = -72) {
|
||||
const lats = [];
|
||||
const lons = [];
|
||||
for (let la = north; la >= south; la -= step)
|
||||
for (let lo = -180; lo < 180; lo += step) {
|
||||
lats.push(la);
|
||||
lons.push(lo);
|
||||
}
|
||||
return {
|
||||
lats,
|
||||
lons,
|
||||
nx: 360 / step,
|
||||
ny: (north - south) / step + 1,
|
||||
lo1: -180,
|
||||
la1: north,
|
||||
dx: step,
|
||||
dy: step,
|
||||
};
|
||||
}
|
||||
|
||||
const OM_CACHE_MS = 60 * 60_000;
|
||||
|
||||
/** Grids persist per browser for an hour: Open-Meteo counts every point. */
|
||||
function readGridCache(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const j = JSON.parse(raw);
|
||||
if (Date.now() - j.at > OM_CACHE_MS) return null;
|
||||
const dec = (b64) =>
|
||||
new Float32Array(
|
||||
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)).buffer,
|
||||
);
|
||||
return {
|
||||
field: { ...j.grid, u: dec(j.u), v: dec(j.v) },
|
||||
speed: dec(j.s),
|
||||
time: j.time,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeGridCache(key, value) {
|
||||
try {
|
||||
const enc = (arr) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(arr.buffer)));
|
||||
const { u, v, ...grid } = value.field;
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
at: Date.now(),
|
||||
time: value.time,
|
||||
grid,
|
||||
u: enc(u),
|
||||
v: enc(v),
|
||||
s: enc(value.speed),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* storage full or blocked */
|
||||
}
|
||||
}
|
||||
|
||||
async function openMeteoField(opts = {}) {
|
||||
const key = `gev-om-grid:${opts.hourlyLevel || '10m'}:${opts.step || 12}`;
|
||||
const cached = readGridCache(key);
|
||||
if (cached) return cached;
|
||||
const value = await fetchOpenMeteoField(opts);
|
||||
writeGridCache(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fetchOpenMeteoField({ hourlyLevel = null, step = 12 } = {}) {
|
||||
const g = omGrid(step);
|
||||
const vars = hourlyLevel
|
||||
? `hourly=wind_speed_${hourlyLevel},wind_direction_${hourlyLevel}&forecast_hours=1`
|
||||
: 'current=wind_speed_10m,wind_direction_10m';
|
||||
// Three parallel chunks: a cold pressure-level request for the whole grid
|
||||
// can take Open-Meteo half a minute; thirds come back in seconds.
|
||||
const chunk = Math.ceil(g.lats.length / 3);
|
||||
const parts = await Promise.all(
|
||||
[0, 1, 2].map((k) => {
|
||||
const la = g.lats.slice(k * chunk, (k + 1) * chunk);
|
||||
const lo = g.lons.slice(k * chunk, (k + 1) * chunk);
|
||||
return getJson(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${la.join(',')}&longitude=${lo.join(',')}&${vars}&timezone=GMT`,
|
||||
{ timeoutMs: 45_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const rows = parts.flat();
|
||||
if (rows.length !== g.lats.length)
|
||||
throw new FeedError('Malformed Open-Meteo wind grid');
|
||||
const n = rows.length;
|
||||
const u = new Float32Array(n);
|
||||
const v = new Float32Array(n);
|
||||
const speed = new Float32Array(n);
|
||||
let time = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = rows[i];
|
||||
const s = hourlyLevel
|
||||
? r.hourly?.[`wind_speed_${hourlyLevel}`]?.[0]
|
||||
: r.current?.wind_speed_10m;
|
||||
const d = hourlyLevel
|
||||
? r.hourly?.[`wind_direction_${hourlyLevel}`]?.[0]
|
||||
: r.current?.wind_direction_10m;
|
||||
time ??= hourlyLevel ? r.hourly?.time?.[0] : r.current?.time;
|
||||
if (!Number.isFinite(s) || !Number.isFinite(d)) continue;
|
||||
[u[i], v[i]] = uv(s, d);
|
||||
speed[i] = s * MS_PER_KMH;
|
||||
}
|
||||
return {
|
||||
field: {
|
||||
u,
|
||||
v,
|
||||
nx: g.nx,
|
||||
ny: g.ny,
|
||||
lo1: g.lo1,
|
||||
la1: g.la1,
|
||||
dx: g.dx,
|
||||
dy: g.dy,
|
||||
},
|
||||
speed,
|
||||
time: time ? Date.parse(`${time}Z`) : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface (10 m) wind: the app's GFS grid when the server can decode it,
|
||||
* otherwise a 12° Open-Meteo grid (GFS/ICON blend, keyless).
|
||||
*/
|
||||
export function createWindFeed() {
|
||||
return createFeed(
|
||||
'wind',
|
||||
async () => {
|
||||
try {
|
||||
const m = await getJson('/api/wind/manifest?model=gfs', {
|
||||
cache: 'no-store',
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
if (!m?.unavailable && m?.grid && m?.gridUrl) {
|
||||
const response = await fetch(m.gridUrl);
|
||||
if (response.ok) {
|
||||
const count = m.grid.nx * m.grid.ny;
|
||||
const values = new Float32Array(await response.arrayBuffer());
|
||||
if (values.length >= count * 2) {
|
||||
const u = values.slice(0, count);
|
||||
const v = values.slice(count, count * 2);
|
||||
return {
|
||||
data: {
|
||||
field: { ...m.grid, u, v },
|
||||
level: '10 m',
|
||||
time: m.fetchedAt || Date.now(),
|
||||
source: 'NOAA GFS 10 m wind',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to Open-Meteo */
|
||||
}
|
||||
const om = await openMeteoField();
|
||||
return {
|
||||
data: {
|
||||
...om,
|
||||
level: '10 m',
|
||||
source: 'Open-Meteo forecast API (12° grid)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 60 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Jet stream: Open-Meteo 250 hPa (≈ FL340) wind on a 12° grid. */
|
||||
export function createJetFeed() {
|
||||
return createFeed(
|
||||
'jet',
|
||||
async () => {
|
||||
const om = await openMeteoField({ hourlyLevel: '250hPa' });
|
||||
return {
|
||||
data: {
|
||||
...om,
|
||||
level: '250 hPa',
|
||||
source: 'Open-Meteo forecast API · 250 hPa (12° grid)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 2 * 3600_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** One point's current 10 m wind (for smoke plumes). */
|
||||
export const pointWind = createKeyedFeed(
|
||||
'point-wind',
|
||||
async ({ lon, lat }) => {
|
||||
// The app's own regional weather route first (server-side, cached).
|
||||
try {
|
||||
const r = await getJson(
|
||||
`/api/weather-effects?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
const w = r?.weather;
|
||||
if (Number.isFinite(w?.windKph) && Number.isFinite(w?.windDirectionDeg))
|
||||
return {
|
||||
speedKmh: w.windKph,
|
||||
fromDeg: w.windDirectionDeg,
|
||||
gustKmh: null,
|
||||
time: Date.parse(w.observedAt) || Date.now(),
|
||||
source: 'Open-Meteo via /api/weather-effects',
|
||||
};
|
||||
} catch {
|
||||
/* direct below */
|
||||
}
|
||||
const j = await getJson(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=wind_speed_10m,wind_direction_10m,wind_gusts_10m&timezone=GMT`,
|
||||
);
|
||||
const c = j?.current;
|
||||
if (!Number.isFinite(c?.wind_speed_10m))
|
||||
throw new FeedError('No point wind');
|
||||
return {
|
||||
speedKmh: c.wind_speed_10m,
|
||||
fromDeg: c.wind_direction_10m,
|
||||
gustKmh: c.wind_gusts_10m,
|
||||
time: Date.parse(`${c.time}Z`),
|
||||
source: 'Open-Meteo',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000, max: 64 },
|
||||
);
|
||||
|
||||
/** Bilinear sample of a north-to-south, seam-wrapped field. */
|
||||
export function sampleField(field, lon, lat) {
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((((lon - lo1) % 360) + 360) % 360) / dx;
|
||||
const y = Math.max(0, Math.min(ny - 1, (la1 - lat) / dy));
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const tx = x - x0;
|
||||
const ty = y - y0;
|
||||
const x1 = (x0 + 1) % nx;
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const at = (arr, xi, yi) => arr[yi * nx + (xi % nx)];
|
||||
const lerp = (arr) =>
|
||||
(at(arr, x0, y0) * (1 - tx) + at(arr, x1, y0) * tx) * (1 - ty) +
|
||||
(at(arr, x0, y1) * (1 - tx) + at(arr, x1, y1) * tx) * ty;
|
||||
return { u: lerp(u), v: lerp(v) };
|
||||
}
|
||||
|
||||
/* ───────────────────────── NHC wind radii ───────────────────────── */
|
||||
|
||||
const NM_KM = 1.852;
|
||||
|
||||
/** Parse an NHC TCM (forecast/advisory) for the current position's radii. */
|
||||
export function parseTcm(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const name =
|
||||
/(?:HURRICANE|TROPICAL STORM|TROPICAL DEPRESSION|SUBTROPICAL STORM|POST-TROPICAL CYCLONE|POTENTIAL TROPICAL CYCLONE)\s+([A-Z-]+)/.exec(
|
||||
text,
|
||||
)?.[1];
|
||||
const center =
|
||||
/CENTER LOCATED NEAR\s+(\d+\.\d)([NS])\s+(\d+\.\d)([EW])\s+AT\s+(\d\d)\/(\d\d)(\d\d)Z/.exec(
|
||||
text,
|
||||
);
|
||||
if (!center) return null;
|
||||
const lat = Number(center[1]) * (center[2] === 'S' ? -1 : 1);
|
||||
const lon = Number(center[3]) * (center[4] === 'W' ? -1 : 1);
|
||||
// The first radii block after "MAX SUSTAINED WINDS" is the current one.
|
||||
const block = text.split(/MAX SUSTAINED WINDS/)[1]?.split(/REPEAT\.\.\./)[0];
|
||||
const radii = [];
|
||||
for (const m of (block || '').matchAll(
|
||||
/(\d\d) KT\.+\s*(\d+)NE\s+(\d+)SE\s+(\d+)SW\s+(\d+)NW/g,
|
||||
)) {
|
||||
radii.push({
|
||||
kt: Number(m[1]),
|
||||
// NE, SE, SW, NW in km
|
||||
quadrantsKm: [m[2], m[3], m[4], m[5]].map((x) => Number(x) * NM_KM),
|
||||
});
|
||||
}
|
||||
const pressure = /MINIMUM CENTRAL PRESSURE\s+(\d+) MB/.exec(text)?.[1];
|
||||
const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
|
||||
return {
|
||||
name: name || null,
|
||||
lat,
|
||||
lon,
|
||||
radii,
|
||||
pressureHpa: pressure ? Number(pressure) : null,
|
||||
windKt: maxWind ? Number(maxWind) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Latest TCM per active storm from api.weather.gov (CORS-enabled, keyless). */
|
||||
export function createTcmFeed() {
|
||||
return createFeed(
|
||||
'nhc-tcm',
|
||||
async () => {
|
||||
const list = await getJson('https://api.weather.gov/products/types/TCM', {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
});
|
||||
const graph = list?.['@graph'] || [];
|
||||
const now = Date.now();
|
||||
const latest = new Map();
|
||||
for (const p of graph) {
|
||||
const t = Date.parse(p.issuanceTime);
|
||||
if (!Number.isFinite(t) || now - t > 18 * 3600_000) continue;
|
||||
const key = p.wmoCollectiveId;
|
||||
if (!latest.has(key)) latest.set(key, p);
|
||||
}
|
||||
const storms = [];
|
||||
await Promise.all(
|
||||
[...latest.values()].slice(0, 8).map(async (p) => {
|
||||
try {
|
||||
const prod = await getJson(
|
||||
`https://api.weather.gov/products/${p.id}`,
|
||||
{ headers: { Accept: 'application/ld+json' } },
|
||||
);
|
||||
const parsed = parseTcm(prod?.productText);
|
||||
if (parsed)
|
||||
storms.push({
|
||||
...parsed,
|
||||
issuedAt: Date.parse(p.issuanceTime),
|
||||
wmo: p.wmoCollectiveId,
|
||||
});
|
||||
} catch {
|
||||
/* one advisory failing drops only its radii */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
storms,
|
||||
source: 'NOAA NHC forecast/advisory via api.weather.gov',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── GNSS integrity ───────────────────────── */
|
||||
|
||||
/** Well-known interference regions worth sampling (anchors, whole degrees). */
|
||||
export const GNSS_ANCHORS = Object.freeze([
|
||||
{ id: 'baltic', name: 'Baltic Sea', lat: 56, lon: 21 },
|
||||
{ id: 'black-sea', name: 'Black Sea', lat: 45, lon: 33 },
|
||||
{ id: 'levant', name: 'Eastern Mediterranean', lat: 34, lon: 34 },
|
||||
{ id: 'gulf', name: 'Persian Gulf', lat: 27, lon: 52 },
|
||||
{ id: 'finland', name: 'Gulf of Finland', lat: 60, lon: 26 },
|
||||
]);
|
||||
|
||||
/** A cell is "anomalous" by the gpsjam band formula on aircraft counts. */
|
||||
function gnssCells(rows, cellDeg = 0.5) {
|
||||
const cells = new Map();
|
||||
for (const r of rows) {
|
||||
const key = `${Math.floor(r.lat / cellDeg)}:${Math.floor(r.lon / cellDeg)}`;
|
||||
let c = cells.get(key);
|
||||
if (!c)
|
||||
cells.set(
|
||||
key,
|
||||
(c = {
|
||||
lat: (Math.floor(r.lat / cellDeg) + 0.5) * cellDeg,
|
||||
lon: (Math.floor(r.lon / cellDeg) + 0.5) * cellDeg,
|
||||
total: 0,
|
||||
bad: 0,
|
||||
}),
|
||||
);
|
||||
c.total++;
|
||||
if (r.degraded || r.gpsLost) c.bad++;
|
||||
}
|
||||
const out = [];
|
||||
for (const c of cells.values()) {
|
||||
if (c.total < 3) continue;
|
||||
c.percent = (100 * Math.max(0, c.bad - 1)) / c.total;
|
||||
c.level = c.percent >= 10 ? 'high' : c.percent >= 2 ? 'medium' : 'low';
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const gnssIntegrity = createKeyedFeed(
|
||||
'gnss',
|
||||
async ({ lat, lon }) => {
|
||||
const j = await getJson(
|
||||
`/api/gnss-integrity?lat=${Math.round(lat)}&lon=${Math.round(lon)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!Array.isArray(j?.rows)) throw new FeedError('Malformed GNSS snapshot');
|
||||
const rows = j.rows.filter(
|
||||
(r) => Number.isFinite(r.lat) && Number.isFinite(r.lon),
|
||||
);
|
||||
return {
|
||||
anchor: j.anchor,
|
||||
rows,
|
||||
cells: gnssCells(rows),
|
||||
degraded: rows.filter((r) => r.degraded || r.gpsLost).length,
|
||||
fetchedAt: j.fetchedAt || Date.now(),
|
||||
source: 'adsb.lol NIC/NACp via /api/gnss-integrity',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 2 * 60_000, max: 24 },
|
||||
);
|
||||
|
||||
/* ───────────────────────── Transit ───────────────────────── */
|
||||
|
||||
export function createTransitCatalogFeed() {
|
||||
return createFeed(
|
||||
'transit-feeds',
|
||||
async () => {
|
||||
const j = await getJson('/api/transit/feeds');
|
||||
if (!Array.isArray(j?.feeds)) throw new FeedError('No transit catalog');
|
||||
return { data: { feeds: j.feeds, source: 'GTFS-realtime agencies' } };
|
||||
},
|
||||
{ ttlMs: 6 * 3600_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export const transitVehicles = createKeyedFeed(
|
||||
'transit',
|
||||
async (feedId) => {
|
||||
const j = await getJson(
|
||||
`/api/transit/vehicles/${encodeURIComponent(feedId)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!Array.isArray(j?.vehicles)) throw new FeedError('No vehicles');
|
||||
return {
|
||||
feedId,
|
||||
name: j.name,
|
||||
fetchedAt: j.fetchedAt,
|
||||
vehicles: j.vehicles
|
||||
.filter((v) => Number.isFinite(v.lat) && Number.isFinite(v.lon))
|
||||
.map((v) => ({
|
||||
id: String(v.id),
|
||||
lat: v.lat,
|
||||
lon: v.lon,
|
||||
bearing: finite(v.bearing),
|
||||
speedMps: finite(v.speedMps),
|
||||
routeId: v.routeId || '',
|
||||
status: v.status || '',
|
||||
t: (v.timestamp || 0) * 1000,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ ttlMs: 20_000, max: 8 },
|
||||
);
|
||||
|
||||
/* ───────────────────────── Air quality ───────────────────────── */
|
||||
|
||||
export const AQ_CITIES = Object.freeze([
|
||||
['Delhi', 77.21, 28.61],
|
||||
['Lahore', 74.35, 31.55],
|
||||
['Dhaka', 90.41, 23.81],
|
||||
['Kolkata', 88.36, 22.57],
|
||||
['Mumbai', 72.88, 19.08],
|
||||
['Beijing', 116.4, 39.9],
|
||||
['Shanghai', 121.47, 31.23],
|
||||
['Chengdu', 104.07, 30.67],
|
||||
['Seoul', 126.98, 37.57],
|
||||
['Tokyo', 139.69, 35.69],
|
||||
['Bangkok', 100.5, 13.76],
|
||||
['Hanoi', 105.85, 21.03],
|
||||
['Jakarta', 106.85, -6.21],
|
||||
['Manila', 120.98, 14.6],
|
||||
['Karachi', 67.01, 24.86],
|
||||
['Tehran', 51.39, 35.69],
|
||||
['Riyadh', 46.68, 24.71],
|
||||
['Cairo', 31.24, 30.04],
|
||||
['Lagos', 3.38, 6.52],
|
||||
['Kinshasa', 15.27, -4.44],
|
||||
['Johannesburg', 28.05, -26.2],
|
||||
['Istanbul', 28.98, 41.01],
|
||||
['Moscow', 37.62, 55.76],
|
||||
['Warsaw', 21.01, 52.23],
|
||||
['Paris', 2.35, 48.86],
|
||||
['London', -0.13, 51.51],
|
||||
['Madrid', -3.7, 40.42],
|
||||
['Milan', 9.19, 45.46],
|
||||
['New York', -74.01, 40.71],
|
||||
['Los Angeles', -118.24, 34.05],
|
||||
['Mexico City', -99.13, 19.43],
|
||||
['Chicago', -87.63, 41.88],
|
||||
['Houston', -95.37, 29.76],
|
||||
['São Paulo', -46.63, -23.55],
|
||||
['Lima', -77.04, -12.05],
|
||||
['Santiago', -70.67, -33.45],
|
||||
['Bogotá', -74.07, 4.71],
|
||||
['Sydney', 151.21, -33.87],
|
||||
['Ulaanbaatar', 106.92, 47.89],
|
||||
['Kathmandu', 85.32, 27.72],
|
||||
]);
|
||||
|
||||
export function createAirQualityFeed() {
|
||||
return createFeed(
|
||||
'air-quality',
|
||||
async () => {
|
||||
const lats = AQ_CITIES.map((c) => c[2]).join(',');
|
||||
const lons = AQ_CITIES.map((c) => c[1]).join(',');
|
||||
const rows = await getJson(
|
||||
`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lats}&longitude=${lons}¤t=us_aqi_pm2_5,pm2_5&hourly=pm2_5&past_days=1&forecast_days=1&timezone=GMT`,
|
||||
{ timeoutMs: 40_000 },
|
||||
);
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
if (list.length !== AQ_CITIES.length)
|
||||
throw new FeedError('Malformed air-quality response');
|
||||
const now = Date.now();
|
||||
const records = [];
|
||||
list.forEach((r, i) => {
|
||||
const aqi = r?.current?.us_aqi_pm2_5;
|
||||
if (!Number.isFinite(aqi)) return;
|
||||
const times = r.hourly?.time || [];
|
||||
const pm = r.hourly?.pm2_5 || [];
|
||||
const series = [];
|
||||
for (let k = 0; k < times.length; k++) {
|
||||
const t = Date.parse(`${times[k]}Z`);
|
||||
if (t <= now && now - t <= 26 * 3600_000 && Number.isFinite(pm[k]))
|
||||
series.push({ t, v: pm[k] });
|
||||
}
|
||||
records.push({
|
||||
id: `aq-${AQ_CITIES[i][0]}`,
|
||||
name: AQ_CITIES[i][0],
|
||||
lon: AQ_CITIES[i][1],
|
||||
lat: AQ_CITIES[i][2],
|
||||
aqi,
|
||||
pm25: r.current.pm2_5,
|
||||
series,
|
||||
t: Date.parse(`${r.current.time}Z`),
|
||||
});
|
||||
});
|
||||
if (!records.length) throw new FeedError('No air-quality values');
|
||||
return {
|
||||
data: {
|
||||
records,
|
||||
source: 'Open-Meteo Air Quality API (CAMS forecast model)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 60 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function aqiColor(aqi) {
|
||||
if (aqi <= 50) return '#5BD46B';
|
||||
if (aqi <= 100) return '#F2D33B';
|
||||
if (aqi <= 150) return '#FF9A3D';
|
||||
if (aqi <= 200) return '#FF4D4D';
|
||||
if (aqi <= 300) return '#B04DFF';
|
||||
return '#8C1A3A';
|
||||
}
|
||||
|
||||
/* ───────────────────────── Regional ADS-B ───────────────────────── */
|
||||
|
||||
/**
|
||||
* The flights proxy answers with OpenSky's global snapshot, or (when
|
||||
* OpenSky is down) a 250 nm adsb.lol snapshot around the anchor. Either way
|
||||
* the records near the anchor are real; this cache keeps one per anchor.
|
||||
*/
|
||||
export function createRegionalFlights(flightFromState) {
|
||||
return createKeyedFeed(
|
||||
'regional-flights',
|
||||
async ({ lat, lon }) => {
|
||||
const j = await getJson(
|
||||
`/api/flights?lat=${lat.toFixed(3)}&lon=${lon.toFixed(3)}`,
|
||||
{ cache: 'no-store', timeoutMs: 30_000 },
|
||||
);
|
||||
if (!Array.isArray(j?.states))
|
||||
throw new FeedError('Malformed flight snapshot');
|
||||
const records = [];
|
||||
for (const s of j.states) {
|
||||
const r = flightFromState(s);
|
||||
if (r) records.push(r);
|
||||
}
|
||||
return { records, time: (j.time || 0) * 1000 };
|
||||
},
|
||||
{ ttlMs: 30_000, max: 12 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* A small bundled gazetteer for naming where something is ("38 km SW of
|
||||
* Miami"). Coordinates are public city centres, rounded.
|
||||
*/
|
||||
import { haversineKm } from './geo.js';
|
||||
|
||||
/** US metros (radar coverage) — [name, lon, lat]. */
|
||||
export const US_METROS = Object.freeze([
|
||||
['New York', -74.01, 40.71],
|
||||
['Los Angeles', -118.24, 34.05],
|
||||
['Chicago', -87.63, 41.88],
|
||||
['Houston', -95.37, 29.76],
|
||||
['Phoenix', -112.07, 33.45],
|
||||
['Philadelphia', -75.17, 39.95],
|
||||
['San Antonio', -98.49, 29.42],
|
||||
['San Diego', -117.16, 32.72],
|
||||
['Dallas', -96.8, 32.78],
|
||||
['Austin', -97.74, 30.27],
|
||||
['Jacksonville', -81.66, 30.33],
|
||||
['Columbus', -83.0, 39.96],
|
||||
['Charlotte', -80.84, 35.23],
|
||||
['Indianapolis', -86.16, 39.77],
|
||||
['San Francisco', -122.42, 37.77],
|
||||
['Seattle', -122.33, 47.61],
|
||||
['Denver', -104.99, 39.74],
|
||||
['Washington', -77.04, 38.9],
|
||||
['Nashville', -86.78, 36.16],
|
||||
['Oklahoma City', -97.52, 35.47],
|
||||
['El Paso', -106.49, 31.76],
|
||||
['Boston', -71.06, 42.36],
|
||||
['Portland', -122.68, 45.52],
|
||||
['Las Vegas', -115.14, 36.17],
|
||||
['Detroit', -83.05, 42.33],
|
||||
['Memphis', -90.05, 35.15],
|
||||
['Louisville', -85.76, 38.25],
|
||||
['Baltimore', -76.61, 39.29],
|
||||
['Milwaukee', -87.91, 43.04],
|
||||
['Albuquerque', -106.65, 35.08],
|
||||
['Tucson', -110.97, 32.22],
|
||||
['Atlanta', -84.39, 33.75],
|
||||
['Kansas City', -94.58, 39.1],
|
||||
['Miami', -80.19, 25.76],
|
||||
['Tampa', -82.46, 27.95],
|
||||
['Orlando', -81.38, 28.54],
|
||||
['New Orleans', -90.07, 29.95],
|
||||
['Minneapolis', -93.27, 44.98],
|
||||
['Cleveland', -81.69, 41.5],
|
||||
['St. Louis', -90.2, 38.63],
|
||||
['Pittsburgh', -79.99, 40.44],
|
||||
['Cincinnati', -84.51, 39.1],
|
||||
['Raleigh', -78.64, 35.78],
|
||||
['Salt Lake City', -111.89, 40.76],
|
||||
['Birmingham', -86.8, 33.52],
|
||||
['Omaha', -95.94, 41.26],
|
||||
['Buffalo', -78.88, 42.89],
|
||||
['Richmond', -77.44, 37.54],
|
||||
['Corpus Christi', -97.4, 27.8],
|
||||
['Mobile', -88.04, 30.69],
|
||||
['Little Rock', -92.29, 34.75],
|
||||
['Des Moines', -93.61, 41.59],
|
||||
['Wichita', -97.34, 37.69],
|
||||
['Boise', -116.2, 43.62],
|
||||
['Spokane', -117.43, 47.66],
|
||||
['Billings', -108.5, 45.78],
|
||||
['Fargo', -96.79, 46.88],
|
||||
['Charleston', -79.93, 32.78],
|
||||
['Savannah', -81.1, 32.08],
|
||||
['Tallahassee', -84.28, 30.44],
|
||||
['Shreveport', -93.75, 32.53],
|
||||
['Amarillo', -101.83, 35.22],
|
||||
['Lubbock', -101.86, 33.58],
|
||||
['Brownsville', -97.5, 25.9],
|
||||
['Key West', -81.78, 24.56],
|
||||
]);
|
||||
|
||||
/** World places and seas for "near X" phrasing — [name, lon, lat]. */
|
||||
export const WORLD_PLACES = Object.freeze([
|
||||
...US_METROS,
|
||||
['Toronto', -79.38, 43.65],
|
||||
['Montréal', -73.57, 45.5],
|
||||
['Vancouver', -123.12, 49.28],
|
||||
['Calgary', -114.07, 51.05],
|
||||
['Mexico City', -99.13, 19.43],
|
||||
['Guadalajara', -103.35, 20.67],
|
||||
['Monterrey', -100.31, 25.69],
|
||||
['Mérida', -89.62, 20.97],
|
||||
['Cancún', -86.85, 21.16],
|
||||
['Puerto Vallarta', -105.23, 20.65],
|
||||
['Mazatlán', -106.41, 23.25],
|
||||
['Cabo San Lucas', -109.92, 22.89],
|
||||
['Acapulco', -99.88, 16.85],
|
||||
['Guatemala City', -90.51, 14.63],
|
||||
['San José', -84.09, 9.93],
|
||||
['Panama City', -79.52, 8.98],
|
||||
['Havana', -82.37, 23.11],
|
||||
['Santo Domingo', -69.93, 18.49],
|
||||
['San Juan', -66.1, 18.47],
|
||||
['Kingston', -76.79, 18.02],
|
||||
['Caracas', -66.9, 10.48],
|
||||
['Bogotá', -74.07, 4.71],
|
||||
['Medellín', -75.56, 6.24],
|
||||
['Quito', -78.47, -0.18],
|
||||
['Lima', -77.04, -12.05],
|
||||
['Manaus', -60.02, -3.12],
|
||||
['Belém', -48.5, -1.46],
|
||||
['Brasília', -47.88, -15.79],
|
||||
['São Paulo', -46.63, -23.55],
|
||||
['Rio de Janeiro', -43.17, -22.91],
|
||||
['Buenos Aires', -58.38, -34.6],
|
||||
['Santiago', -70.67, -33.45],
|
||||
['La Paz', -68.15, -16.5],
|
||||
['Asunción', -57.58, -25.26],
|
||||
['Reykjavík', -21.9, 64.15],
|
||||
['London', -0.13, 51.51],
|
||||
['Paris', 2.35, 48.86],
|
||||
['Madrid', -3.7, 40.42],
|
||||
['Lisbon', -9.14, 38.72],
|
||||
['Rome', 12.5, 41.9],
|
||||
['Berlin', 13.4, 52.52],
|
||||
['Warsaw', 21.01, 52.23],
|
||||
['Stockholm', 18.07, 59.33],
|
||||
['Helsinki', 24.94, 60.17],
|
||||
['Tallinn', 24.75, 59.44],
|
||||
['Riga', 24.11, 56.95],
|
||||
['Vilnius', 25.28, 54.69],
|
||||
['Kaliningrad', 20.51, 54.71],
|
||||
['Gdańsk', 18.65, 54.35],
|
||||
['St Petersburg', 30.32, 59.94],
|
||||
['Moscow', 37.62, 55.76],
|
||||
['Kyiv', 30.52, 50.45],
|
||||
['Odesa', 30.73, 46.48],
|
||||
['Sevastopol', 33.52, 44.62],
|
||||
['Istanbul', 28.98, 41.01],
|
||||
['Athens', 23.73, 37.98],
|
||||
['Nicosia', 33.38, 35.19],
|
||||
['Beirut', 35.5, 33.89],
|
||||
['Tel Aviv', 34.78, 32.09],
|
||||
['Damascus', 36.29, 33.51],
|
||||
['Cairo', 31.24, 30.04],
|
||||
['Baghdad', 44.36, 33.31],
|
||||
['Tehran', 51.39, 35.69],
|
||||
['Kuwait City', 47.98, 29.38],
|
||||
['Riyadh', 46.68, 24.71],
|
||||
['Doha', 51.53, 25.29],
|
||||
['Dubai', 55.27, 25.2],
|
||||
['Muscat', 58.4, 23.59],
|
||||
['Bandar Abbas', 56.27, 27.18],
|
||||
['Karachi', 67.01, 24.86],
|
||||
['Lahore', 74.35, 31.55],
|
||||
['Delhi', 77.21, 28.61],
|
||||
['Mumbai', 72.88, 19.08],
|
||||
['Chennai', 80.27, 13.08],
|
||||
['Kolkata', 88.36, 22.57],
|
||||
['Dhaka', 90.41, 23.81],
|
||||
['Kathmandu', 85.32, 27.72],
|
||||
['Colombo', 79.86, 6.93],
|
||||
['Yangon', 96.2, 16.87],
|
||||
['Bangkok', 100.5, 13.76],
|
||||
['Hanoi', 105.85, 21.03],
|
||||
['Ho Chi Minh City', 106.63, 10.82],
|
||||
['Kuala Lumpur', 101.69, 3.14],
|
||||
['Singapore', 103.82, 1.35],
|
||||
['Jakarta', 106.85, -6.21],
|
||||
['Surabaya', 112.75, -7.25],
|
||||
['Manila', 120.98, 14.6],
|
||||
['Cebu', 123.89, 10.32],
|
||||
['Davao', 125.61, 7.07],
|
||||
['Hong Kong', 114.17, 22.32],
|
||||
['Taipei', 121.56, 25.03],
|
||||
['Guangzhou', 113.26, 23.13],
|
||||
['Shanghai', 121.47, 31.23],
|
||||
['Beijing', 116.4, 39.9],
|
||||
['Seoul', 126.98, 37.57],
|
||||
['Busan', 129.08, 35.18],
|
||||
['Tokyo', 139.69, 35.69],
|
||||
['Osaka', 135.5, 34.69],
|
||||
['Okinawa', 127.68, 26.21],
|
||||
['Sapporo', 141.35, 43.06],
|
||||
['Guam', 144.79, 13.44],
|
||||
['Port Moresby', 147.18, -9.44],
|
||||
['Darwin', 130.84, -12.46],
|
||||
['Cairns', 145.77, -16.92],
|
||||
['Brisbane', 153.03, -27.47],
|
||||
['Sydney', 151.21, -33.87],
|
||||
['Melbourne', 144.96, -37.81],
|
||||
['Perth', 115.86, -31.95],
|
||||
['Auckland', 174.76, -36.85],
|
||||
['Suva', 178.44, -18.14],
|
||||
['Nouméa', 166.46, -22.28],
|
||||
['Honolulu', -157.86, 21.31],
|
||||
['Anchorage', -149.9, 61.22],
|
||||
['Lagos', 3.38, 6.52],
|
||||
['Accra', -0.19, 5.6],
|
||||
['Dakar', -17.47, 14.72],
|
||||
['Kinshasa', 15.27, -4.44],
|
||||
['Nairobi', 36.82, -1.29],
|
||||
['Addis Ababa', 38.75, 9.03],
|
||||
['Khartoum', 32.53, 15.5],
|
||||
['Johannesburg', 28.05, -26.2],
|
||||
['Cape Town', 18.42, -33.92],
|
||||
['Antananarivo', 47.52, -18.88],
|
||||
['Maputo', 32.57, -25.97],
|
||||
['Luanda', 13.23, -8.84],
|
||||
['Ulaanbaatar', 106.92, 47.89],
|
||||
['Almaty', 76.89, 43.24],
|
||||
['Novosibirsk', 82.93, 55.03],
|
||||
['Vladivostok', 131.89, 43.12],
|
||||
]);
|
||||
|
||||
const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
|
||||
/** Compass bearing (deg) from a to b. */
|
||||
export function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r);
|
||||
const x =
|
||||
Math.cos(lat1 * r) * Math.sin(lat2 * r) -
|
||||
Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r);
|
||||
return (((Math.atan2(y, x) / r) % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
export function compass(deg) {
|
||||
return DIRS[Math.round((((deg % 360) + 360) % 360) / 45) % 8];
|
||||
}
|
||||
|
||||
/** Nearest bundled place: { name, km, dir } (dir is from the place). */
|
||||
export function nearestPlace(lat, lon, list = WORLD_PLACES) {
|
||||
let best = null;
|
||||
for (const [name, plon, plat] of list) {
|
||||
const km = haversineKm(lat, lon, plat, plon);
|
||||
if (!best || km < best.km) best = { name, km, plon, plat };
|
||||
}
|
||||
if (!best) return null;
|
||||
return {
|
||||
name: best.name,
|
||||
km: Math.round(best.km),
|
||||
dir: compass(bearingDeg(best.plat, best.plon, lat, lon)),
|
||||
};
|
||||
}
|
||||
|
||||
/** "Miami", "38 km SW of Miami" or "640 km E of Manila". */
|
||||
export function describePlace(lat, lon, list) {
|
||||
const p = nearestPlace(lat, lon, list);
|
||||
if (!p) return '';
|
||||
if (p.km < 15) return p.name;
|
||||
return `${p.km.toLocaleString('en-US')} km ${p.dir} of ${p.name}`;
|
||||
}
|
||||
@@ -237,6 +237,117 @@ const painters = {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
},
|
||||
bolt(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(28, 3);
|
||||
ctx.lineTo(12, 27);
|
||||
ctx.lineTo(23, 27);
|
||||
ctx.lineTo(19, 45);
|
||||
ctx.lineTo(37, 18);
|
||||
ctx.lineTo(26, 18);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
aq(ctx, s) {
|
||||
const g = ctx.createRadialGradient(s / 2, s / 2, 4, s / 2, s / 2, s / 2);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.9)');
|
||||
g.addColorStop(0.45, 'rgba(255,255,255,0.45)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, s, s);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, 9, 0, Math.PI * 2);
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
bus(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 4);
|
||||
ctx.lineTo(33, 14);
|
||||
ctx.lineTo(33, 42);
|
||||
ctx.lineTo(15, 42);
|
||||
ctx.lineTo(15, 14);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
post(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8, 10);
|
||||
ctx.lineTo(40, 10);
|
||||
ctx.lineTo(40, 32);
|
||||
ctx.lineTo(22, 32);
|
||||
ctx.lineTo(13, 41);
|
||||
ctx.lineTo(14, 32);
|
||||
ctx.lineTo(8, 32);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
witness(ctx) {
|
||||
// a seal: a rosette with a check
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const a = (i / 24) * Math.PI * 2;
|
||||
const r = i % 2 ? 17 : 21;
|
||||
ctx.lineTo(24 + r * Math.cos(a), 24 + r * Math.sin(a));
|
||||
}
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(16, 24);
|
||||
ctx.lineTo(22, 30);
|
||||
ctx.lineTo(33, 18);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.75)';
|
||||
ctx.stroke();
|
||||
},
|
||||
eye(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(4, 24);
|
||||
ctx.quadraticCurveTo(24, 4, 44, 24);
|
||||
ctx.quadraticCurveTo(24, 44, 4, 24);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, 7, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fill();
|
||||
},
|
||||
live(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, 9, 0, Math.PI * 2);
|
||||
stroke(ctx, 3);
|
||||
for (const r of [15, 21]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, r, -0.7, 0.7);
|
||||
ctx.moveTo(24 - r * Math.cos(0.7), 24 - r * Math.sin(0.7));
|
||||
ctx.arc(24, 24, r, Math.PI - 0.7, Math.PI + 0.7);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
hologram(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 3);
|
||||
ctx.lineTo(42, 14);
|
||||
ctx.lineTo(42, 34);
|
||||
ctx.lineTo(24, 45);
|
||||
ctx.lineTo(6, 34);
|
||||
ctx.lineTo(6, 14);
|
||||
ctx.closePath();
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(6, 14);
|
||||
ctx.lineTo(24, 25);
|
||||
ctx.lineTo(42, 14);
|
||||
ctx.moveTo(24, 25);
|
||||
ctx.lineTo(24, 45);
|
||||
ctx.stroke();
|
||||
},
|
||||
};
|
||||
|
||||
/** `{ image, imageId }` for a named glyph. */
|
||||
|
||||
+188
-5
@@ -6,6 +6,30 @@
|
||||
* 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).
|
||||
*
|
||||
* Round-two contract additions (full notes in ./API.md):
|
||||
*
|
||||
* globe.setViewer({ lon, lat } | null) / globe.viewer()
|
||||
* globe.situations({ observer? }) → Promise<Situation[]>, each Situation now
|
||||
* also has band: 'orbit'|'sky'|'ground'|'inside',
|
||||
* archetype: 'sensor'|'eye'|'track'|'number', telemetry: string,
|
||||
* media?: { kind:'image', url, refreshMs, cameraId, headingDeg? },
|
||||
* series?: [{ t, v }], seriesLabel?, headline?: { value, unit, label },
|
||||
* distanceKm? (once a viewer is known)
|
||||
* globe.omitted() → [{ genre, reason }] globe.timings() → { genre: ms }
|
||||
* globe.thumbnail(situation, { width=360, height=480, dpr=2 })
|
||||
* → Promise<string> (blob: URL of a composited JPEG; never the viewer)
|
||||
* globe.addPin({ id, lon, lat, kind:'post'|'witness'|'eye'|'live'|'hologram',
|
||||
* headingDeg?, fovDeg?, rangeM?, color?, label?, altM? }) → id
|
||||
* globe.removePin(id) → boolean globe.pins() → Pin[]
|
||||
* 'pick' → { kind:'pin', pinKind, id, title, subtitle, lon, lat, meta }
|
||||
* globe.nearestEye(lon, lat, km=50)
|
||||
* → Promise<{ id, title, lon, lat, distanceKm, headingDeg, fovDeg,
|
||||
* imageUrl, city, provider } | null>
|
||||
* globe.eyeFrameUrl(cameraId) → string | null
|
||||
* labels() entries may carry `detail` ('CAT 2 · 85 KT · 974 HPA', 'SQ 7700')
|
||||
* New layers: radar, clouds, lightning, wind, jet, air-quality, gnss,
|
||||
* transit; cyclones gain a filled cone and NHC wind radii.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createImagery } from './imagery.js';
|
||||
@@ -30,6 +54,19 @@ import {
|
||||
createTleFeed,
|
||||
createVesselsFeed,
|
||||
} from './feeds.js';
|
||||
import {
|
||||
createAirQualityFeed,
|
||||
createJetFeed,
|
||||
createRegionalFlights,
|
||||
createTcmFeed,
|
||||
createTransitCatalogFeed,
|
||||
createWeatherManifestFeed,
|
||||
createWindFeed,
|
||||
} from './feedsWeather.js';
|
||||
import { cctvFrameUrl, flightFromState } from './feeds.js';
|
||||
import { createPins } from './pins.js';
|
||||
import { createCompositor } from './compositor.js';
|
||||
import { haversineKm } from './geo.js';
|
||||
import { applyModelAtmosphereWorkaround } from '../../src/app/atmosphereCompat.js';
|
||||
|
||||
export { LENSES, FAMILIES };
|
||||
@@ -46,6 +83,7 @@ export const DEFAULT_LAYERS = Object.freeze([
|
||||
|
||||
const LABEL_PRIORITY = [
|
||||
'squawk',
|
||||
'air-quality',
|
||||
'iss',
|
||||
'cyclones',
|
||||
'launches',
|
||||
@@ -67,6 +105,7 @@ const LABEL_PRIORITY = [
|
||||
|
||||
const PLANETARY_LABELS = new Set([
|
||||
'squawk',
|
||||
'air-quality',
|
||||
'iss',
|
||||
'cyclones',
|
||||
'launches',
|
||||
@@ -93,6 +132,14 @@ const DATA_CREDITS = {
|
||||
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)',
|
||||
radar: 'Radar: NOAA/NWS MRMS via nowCOAST',
|
||||
clouds: 'Satellite IR: NOAA/NESDIS via nowCOAST',
|
||||
lightning: 'Lightning: NOAA/NWS nowCOAST (Vaisala NLDN/GLD360)',
|
||||
wind: 'Wind: NOAA GFS / Open-Meteo',
|
||||
jet: 'Jet stream: Open-Meteo (250 hPa)',
|
||||
'air-quality': 'Air quality: Open-Meteo / Copernicus CAMS',
|
||||
gnss: 'GNSS integrity: adsb.lol (ODbL), gpsjam.org method',
|
||||
transit: 'Transit: agency GTFS-realtime feeds (see per-feed credit)',
|
||||
};
|
||||
|
||||
function viewerOptions(creditContainer, options) {
|
||||
@@ -266,6 +313,15 @@ export async function createGlobe(container, options = {}) {
|
||||
cyclones: createCyclonesFeed(eonet),
|
||||
cctv: createCctvFeed(),
|
||||
flood: createFloodFeed(),
|
||||
radar: createWeatherManifestFeed('radar'),
|
||||
clouds: createWeatherManifestFeed('clouds'),
|
||||
lightning: createWeatherManifestFeed('lightning'),
|
||||
wind: createWindFeed(),
|
||||
jet: createJetFeed(),
|
||||
tcm: createTcmFeed(),
|
||||
airQuality: createAirQualityFeed(),
|
||||
transitCatalog: createTransitCatalogFeed(),
|
||||
regionalFlights: createRegionalFlights(flightFromState),
|
||||
};
|
||||
|
||||
let layersTimer = null;
|
||||
@@ -285,6 +341,24 @@ export async function createGlobe(container, options = {}) {
|
||||
},
|
||||
};
|
||||
const layers = buildLayers(ctx, feeds);
|
||||
const pins = createPins({ scene, requestRender });
|
||||
const compositor = createCompositor({ feeds });
|
||||
// Slow keyless sources warm in the background so the first feed has them.
|
||||
setTimeout(() => {
|
||||
for (const f of [
|
||||
feeds.jet,
|
||||
feeds.tcm,
|
||||
feeds.airQuality,
|
||||
feeds.cctv,
|
||||
feeds.lightning,
|
||||
feeds.radar,
|
||||
feeds.clouds,
|
||||
])
|
||||
f.get().catch(() => {});
|
||||
}, 1500);
|
||||
let viewerPos = opts.observer
|
||||
? { lon: opts.observer.lon, lat: opts.observer.lat }
|
||||
: null;
|
||||
|
||||
// ── Selection visuals ────────────────────────────────────────────
|
||||
const selectionRoot = scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
@@ -437,7 +511,15 @@ export async function createGlobe(container, options = {}) {
|
||||
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)) {
|
||||
if (id && typeof id === 'object' && id.layer === 'pins') {
|
||||
if (selected) api.select(null);
|
||||
emit('pick', pins.pick(id.key));
|
||||
} else 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 {
|
||||
@@ -458,6 +540,7 @@ export async function createGlobe(container, options = {}) {
|
||||
drawSelection();
|
||||
moved = true;
|
||||
}
|
||||
if (pins.tick()) moved = true;
|
||||
if (moved) requestRender();
|
||||
}, 1000);
|
||||
|
||||
@@ -751,10 +834,92 @@ export async function createGlobe(container, options = {}) {
|
||||
},
|
||||
|
||||
situations(extra = {}) {
|
||||
return buildSituations(
|
||||
{ feeds, timeMs },
|
||||
{ observer: extra.observer ?? opts.observer },
|
||||
);
|
||||
const observer = extra.observer ?? viewerPos ?? opts.observer;
|
||||
return buildSituations({ feeds, timeMs }, { observer }).then((list) => {
|
||||
if (observer && Number.isFinite(observer.lat))
|
||||
for (const s of list)
|
||||
s.distanceKm = Math.round(
|
||||
haversineKm(observer.lat, observer.lon, s.lat, s.lon),
|
||||
);
|
||||
return list;
|
||||
});
|
||||
},
|
||||
|
||||
/** Genres the last situations() call left out, and why (for QA/UI). */
|
||||
omitted() {
|
||||
return buildSituations.lastOmitted || [];
|
||||
},
|
||||
|
||||
/** Per-genre build times (ms since the feeds resolved) of the last call. */
|
||||
timings() {
|
||||
return buildSituations.lastTimings || {};
|
||||
},
|
||||
|
||||
/** Where the person is (for distanceKm and "near you"); null clears. */
|
||||
setViewer(pos) {
|
||||
viewerPos =
|
||||
pos && Number.isFinite(pos.lon) && Number.isFinite(pos.lat)
|
||||
? { lon: pos.lon, lat: pos.lat }
|
||||
: null;
|
||||
emit('viewer', viewerPos);
|
||||
},
|
||||
|
||||
viewer() {
|
||||
return viewerPos ? { ...viewerPos } : null;
|
||||
},
|
||||
|
||||
/** Card compositor: Promise<object URL> (see API.md). */
|
||||
thumbnail(situation, options) {
|
||||
return compositor.thumbnail(situation, options);
|
||||
},
|
||||
|
||||
addPin(pin) {
|
||||
return pins.add(pin);
|
||||
},
|
||||
|
||||
removePin(id) {
|
||||
return pins.remove(id);
|
||||
},
|
||||
|
||||
pins() {
|
||||
return pins.list();
|
||||
},
|
||||
|
||||
/** Nearest public camera within `km`, from the CCTV catalog. */
|
||||
async nearestEye(lon, lat, km = 50) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
let data;
|
||||
try {
|
||||
data = await feeds.cctv.get();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let best = null;
|
||||
for (const c of data.records) {
|
||||
if (Math.abs(c.lat - lat) > km / 100 + 0.5) continue;
|
||||
const d = haversineKm(lat, lon, c.lat, c.lon);
|
||||
if (d <= km && (!best || d < best.d)) best = { c, d };
|
||||
}
|
||||
if (!best) return null;
|
||||
const c = best.c;
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
distanceKm: Math.round(best.d * 100) / 100,
|
||||
headingDeg: c.heading,
|
||||
fovDeg: c.fovDeg,
|
||||
imageUrl: cctvFrameUrl(c),
|
||||
city: c.city,
|
||||
provider: c.provider,
|
||||
};
|
||||
},
|
||||
|
||||
/** A fresh same-origin frame URL for a catalog camera (or null). */
|
||||
eyeFrameUrl(cameraId) {
|
||||
const c = feeds.cctv.data?.records.find((r) => r.id === cameraId);
|
||||
return c ? cctvFrameUrl(c) : null;
|
||||
},
|
||||
|
||||
credits() {
|
||||
@@ -803,6 +968,7 @@ export async function createGlobe(container, options = {}) {
|
||||
if (!glyphs) continue;
|
||||
// Semantic zoom for words too: from orbit only the headline layers.
|
||||
if (planetary && !PLANETARY_LABELS.has(layer.id)) continue;
|
||||
if (layer.id === 'lightning') continue; // the glow is the label
|
||||
const rank = LABEL_PRIORITY.indexOf(layer.id);
|
||||
for (let i = 0; i < glyphs.length; i++) {
|
||||
const bb = glyphs.get(i);
|
||||
@@ -821,6 +987,19 @@ export async function createGlobe(container, options = {}) {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < pins.billboards.length; i++) {
|
||||
const bb = pins.billboards.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;
|
||||
candidates.push({
|
||||
layer: { id: 'pins', pick: (key) => pins.pick(key) },
|
||||
key: bb.id.key,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
score: -50,
|
||||
});
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
// Greedy placement: a label never overlaps one already placed.
|
||||
const placed = [];
|
||||
@@ -854,6 +1033,8 @@ export async function createGlobe(container, options = {}) {
|
||||
layer: c.layer.id,
|
||||
kind: pick.kind,
|
||||
title: pick.title,
|
||||
...(pick.labelDetail ? { detail: pick.labelDetail } : {}),
|
||||
...(pick.pinKind ? { pinKind: pick.pinKind } : {}),
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
});
|
||||
@@ -886,6 +1067,8 @@ export async function createGlobe(container, options = {}) {
|
||||
removeMoveEnd();
|
||||
handler.destroy();
|
||||
for (const layer of layers.values()) layer.destroy();
|
||||
pins.destroy();
|
||||
compositor.destroy();
|
||||
governor.destroy();
|
||||
post.destroy();
|
||||
viewer.destroy();
|
||||
|
||||
@@ -176,6 +176,7 @@ export class AircraftLayer extends Layer {
|
||||
.join(' · '),
|
||||
lon,
|
||||
lat,
|
||||
...(r.emergency ? { labelDetail: `SQ ${r.squawk}` } : {}),
|
||||
meta: {
|
||||
layer: this.id,
|
||||
icao24: r.id,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSkyLayers } from './air.js';
|
||||
import { createOrbitLayers } from './orbit.js';
|
||||
import { createHazardLayers } from './hazards.js';
|
||||
import { createSeaLayers, createEyesLayers } from './sea.js';
|
||||
import { createWeatherLayers } from './weather.js';
|
||||
|
||||
export const FAMILIES = Object.freeze([
|
||||
'Sky',
|
||||
@@ -62,7 +63,8 @@ export const CATALOG = Object.freeze([
|
||||
['Weather', 'radar', 'Radar'],
|
||||
['Weather', 'clouds', 'Clouds'],
|
||||
['Weather', 'lightning', 'Lightning'],
|
||||
['Weather', 'wind', 'Wind'],
|
||||
['Weather', 'wind', 'Wind (10 m)'],
|
||||
['Weather', 'jet', 'Jet stream (250 hPa)'],
|
||||
['Weather', 'forecast', 'Forecast models'],
|
||||
['Infra', 'datacenters', 'Datacenters'],
|
||||
['Infra', 'dams', 'Dams'],
|
||||
@@ -81,6 +83,7 @@ export function buildLayers(ctx, feeds) {
|
||||
...createOrbitLayers(ctx, feeds),
|
||||
...createHazardLayers(ctx, feeds),
|
||||
...createEyesLayers(ctx, feeds),
|
||||
...createWeatherLayers(ctx, feeds),
|
||||
])
|
||||
live.set(layer.id, layer);
|
||||
const out = new Map();
|
||||
|
||||
@@ -224,7 +224,7 @@ export function createHazardLayers(ctx, feeds) {
|
||||
return [
|
||||
quakes,
|
||||
fires,
|
||||
new CycloneLayer(ctx, { feed: feeds.cyclones }),
|
||||
new CycloneLayer(ctx, { feed: feeds.cyclones, tcm: feeds.tcm }),
|
||||
new FloodLayer(ctx, { feed: feeds.flood }),
|
||||
volcanoes,
|
||||
];
|
||||
@@ -255,8 +255,55 @@ export function stormPositionAt(storm, ms) {
|
||||
return null; // beyond the forecast
|
||||
}
|
||||
|
||||
const SAFFIR = [
|
||||
[137, 'CAT 5'],
|
||||
[113, 'CAT 4'],
|
||||
[96, 'CAT 3'],
|
||||
[83, 'CAT 2'],
|
||||
[64, 'CAT 1'],
|
||||
[34, 'TS'],
|
||||
[0, 'TD'],
|
||||
];
|
||||
|
||||
/** Saffir-Simpson category (or TS/TD) for a 1-min sustained wind in kt. */
|
||||
export function stormCategory(windKt) {
|
||||
if (!Number.isFinite(windKt)) return '';
|
||||
return SAFFIR.find(([kt]) => windKt >= kt)[1];
|
||||
}
|
||||
|
||||
/** Ring of a quadrant wind-radii shape ([NE, SE, SW, NW] km) around a point. */
|
||||
export function radiiRing(lon, lat, quadrantsKm, stepDeg = 6) {
|
||||
const ring = [];
|
||||
const cosLat = Math.max(0.05, Math.cos((lat * Math.PI) / 180));
|
||||
for (let a = 0; a <= 360; a += stepDeg) {
|
||||
const q = Math.min(3, Math.floor((a % 360) / 90));
|
||||
const r = quadrantsKm[q];
|
||||
const rad = (a * Math.PI) / 180;
|
||||
ring.push([
|
||||
lon + (r * Math.sin(rad)) / (111.32 * cosLat),
|
||||
lat + (r * Math.cos(rad)) / 111.32,
|
||||
]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
export const RADII_COLORS = { 34: '#FFD84D', 50: '#FF9A3D', 64: '#FF3B30' };
|
||||
|
||||
/** Match an NHC TCM (wind radii) to a storm by position (≤ 250 km). */
|
||||
export function radiiFor(storm, tcm) {
|
||||
let best = null;
|
||||
for (const t of tcm?.storms || []) {
|
||||
const d = Math.hypot(
|
||||
(t.lon - storm.lon) * Math.cos((storm.lat * Math.PI) / 180),
|
||||
t.lat - storm.lat,
|
||||
);
|
||||
if (d < 2.3 && (!best || d < best.d)) best = { d, t };
|
||||
}
|
||||
return best?.t || null;
|
||||
}
|
||||
|
||||
class CycloneLayer extends Layer {
|
||||
constructor(ctx, { feed }) {
|
||||
constructor(ctx, { feed, tcm }) {
|
||||
super(ctx, {
|
||||
id: 'cyclones',
|
||||
family: 'Hazards',
|
||||
@@ -264,6 +311,8 @@ class CycloneLayer extends Layer {
|
||||
pollMs: 15 * 60_000,
|
||||
});
|
||||
this.feed = feed;
|
||||
this.tcm = tcm;
|
||||
this.fills = null;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#FFFFFF'),
|
||||
@@ -284,6 +333,64 @@ class CycloneLayer extends Layer {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
this._draw(true);
|
||||
// Wind radii arrive from a second source; the cone and track never wait.
|
||||
this.tcm
|
||||
?.get()
|
||||
.then(() => this.on && this._fills(this.feed.data?.records || []))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Filled cone of uncertainty and 34/50/64-kt wind-radii quadrants. */
|
||||
_fills(storms) {
|
||||
if (this.fills) this.root.remove(this.fills);
|
||||
this.fills = null;
|
||||
const instances = [];
|
||||
const poly = (ring, css, alpha, height) => {
|
||||
if (!ring || ring.length < 4) return;
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(ring.flat()),
|
||||
),
|
||||
height,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color(css, alpha),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (const s of storms) {
|
||||
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
|
||||
const tcm = radiiFor(s, this.tcm?.data);
|
||||
if (tcm) s.radii = tcm.radii;
|
||||
for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) {
|
||||
if (!r.quadrantsKm.some((k) => k > 0)) continue;
|
||||
poly(
|
||||
radiiRing(s.lon, s.lat, r.quadrantsKm),
|
||||
RADII_COLORS[r.kt] || '#FFD84D',
|
||||
r.kt === 64 ? 0.26 : r.kt === 50 ? 0.18 : 0.12,
|
||||
2500 + r.kt * 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!instances.length) return;
|
||||
this.fills = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
}),
|
||||
);
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
_draw(full) {
|
||||
@@ -308,7 +415,10 @@ class CycloneLayer extends Layer {
|
||||
}
|
||||
this.count = items.length;
|
||||
this.renderer.setItems(items);
|
||||
if (full) this._lines(data.records);
|
||||
if (full) {
|
||||
this._lines(data.records);
|
||||
this._fills(data.records);
|
||||
}
|
||||
}
|
||||
|
||||
_lines(storms) {
|
||||
@@ -402,9 +512,17 @@ class CycloneLayer extends Layer {
|
||||
.join(' · '),
|
||||
lon: s.now.lon,
|
||||
lat: s.now.lat,
|
||||
labelDetail: [
|
||||
stormCategory(s.now.windKt),
|
||||
s.now.windKt ? `${Math.round(s.now.windKt)} KT` : null,
|
||||
s.pressureHpa ? `${s.pressureHpa} HPA` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
meta: {
|
||||
layer: this.id,
|
||||
windKt: s.now.windKt ?? null,
|
||||
radii: s.radii ?? null,
|
||||
pressureHpa: s.pressureHpa ?? null,
|
||||
movement: s.movement ?? null,
|
||||
basin: s.basin,
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* Weather family (radar, satellite IR clouds, lightning, surface wind, jet
|
||||
* stream) plus the round-two Ground/Infra/Hazards layers that share the same
|
||||
* plumbing (transit vehicles, GNSS interference, air quality).
|
||||
*
|
||||
* Rasters come from NOAA nowCOAST through the app's /api/weather proxy as
|
||||
* whole-extent frames and are drawn as one unlit, translucent rectangle each
|
||||
* (a few kilometres above the ground, where the weather is), so the night
|
||||
* side does not swallow them the way lit imagery layers would. Scrubbing the
|
||||
* time ribbon swaps to the frame valid at that time (radar ≈ 50 min of 4-min
|
||||
* frames, lightning 3 h of 15-min frames, IR 6 h of hourly frames). Wind uses
|
||||
* the desktop app's GPU streamline renderer (src/layers/wind/gpuRendering.js).
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ContactRenderer,
|
||||
Layer,
|
||||
color,
|
||||
FAMILY_COLORS,
|
||||
inRect,
|
||||
} from './base.js';
|
||||
import { createWindGpuRendering } from '../../../src/layers/wind/gpuRendering.js';
|
||||
import {
|
||||
aqiColor,
|
||||
frameAt,
|
||||
gnssIntegrity,
|
||||
GNSS_ANCHORS,
|
||||
transitVehicles,
|
||||
weatherImageUrl,
|
||||
} from '../feedsWeather.js';
|
||||
import { haversineKm, relTime } from '../geo.js';
|
||||
|
||||
const WX = FAMILY_COLORS.Weather;
|
||||
|
||||
/* ───────────────────────── Frame decoding (shared) ───────────────────────── */
|
||||
|
||||
const frameCache = new Map(); // url → Promise<canvas>
|
||||
const FRAME_CACHE_MAX = 8;
|
||||
|
||||
/** Decode a same-origin weather frame into a canvas (IR gets an alpha key). */
|
||||
async function decodeFrame(url, product) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Weather frame HTTP ${response.status}`);
|
||||
const bitmap = await createImageBitmap(await response.blob());
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const g = canvas.getContext('2d', { willReadFrequently: true });
|
||||
g.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
if (product === 'clouds' || product === 'clouds-regional') {
|
||||
// Key the IR to clouds: warm surfaces clear, cloud tops white with a
|
||||
// soft ramp (the repo's cloud key is tuned for a lit globe; this one
|
||||
// also has to read on the unlit night side).
|
||||
const px = g.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const d = px.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const l = d[i];
|
||||
const t = Math.max(0, Math.min(1, (l - 95) / 95));
|
||||
const a = t * t * (3 - 2 * t);
|
||||
d[i + 3] = (d[i + 3] * a * 215) / 255;
|
||||
// Unlit clouds must not blow out under bloom: grey-white body,
|
||||
// coldest tops leaning icy blue.
|
||||
const cold = Math.max(0, (l - 200) / 55);
|
||||
d[i] = 168 + 42 * a - 22 * cold;
|
||||
d[i + 1] = 176 + 40 * a - 6 * cold;
|
||||
d[i + 2] = 192 + 36 * a + 10 * cold;
|
||||
}
|
||||
g.putImageData(px, 0, 0);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function cached(url, make, keep) {
|
||||
let p = frameCache.get(url);
|
||||
if (p) {
|
||||
frameCache.delete(url);
|
||||
frameCache.set(url, p);
|
||||
return p;
|
||||
}
|
||||
p = make();
|
||||
p.catch(() => frameCache.delete(url));
|
||||
if (!keep) return p;
|
||||
frameCache.set(url, p);
|
||||
while (frameCache.size > FRAME_CACHE_MAX)
|
||||
frameCache.delete(frameCache.keys().next().value);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Decode a same-origin weather frame into a canvas (IR gets a cloud key). */
|
||||
export function loadWeatherFrame(
|
||||
product,
|
||||
time,
|
||||
width = 2048,
|
||||
{ keep = true } = {},
|
||||
) {
|
||||
const url = weatherImageUrl(product, time, width);
|
||||
return cached(url, () => decodeFrame(url, product), keep);
|
||||
}
|
||||
|
||||
/** A 2:1 detail window (0.25° grid) covering a view rect inside bounds. */
|
||||
export function detailWindow(rect, bounds) {
|
||||
if (!rect || rect.west > rect.east) return null;
|
||||
const lonSpan = rect.east - rect.west;
|
||||
const latSpan = rect.north - rect.south;
|
||||
let width = Math.max(1, lonSpan * 1.1, latSpan * 2.2);
|
||||
width = Math.ceil(width / 0.5) * 0.5;
|
||||
const height = width / 2;
|
||||
if (width >= bounds.east - bounds.west - 0.5) return null;
|
||||
if (height >= bounds.north - bounds.south - 0.5) return null;
|
||||
const snap = (v) => Math.round(v / 0.25) * 0.25;
|
||||
const cLon = (rect.west + rect.east) / 2;
|
||||
const cLat = (rect.south + rect.north) / 2;
|
||||
const minW = Math.ceil(bounds.west / 0.25) * 0.25;
|
||||
const maxW = Math.floor((bounds.east - width) / 0.25) * 0.25;
|
||||
const minS = Math.ceil(bounds.south / 0.25) * 0.25;
|
||||
const maxS = Math.floor((bounds.north - height) / 0.25) * 0.25;
|
||||
const west = Math.max(minW, Math.min(maxW, snap(cLon - width / 2)));
|
||||
const south = Math.max(minS, Math.min(maxS, snap(cLat - height / 2)));
|
||||
return { west, south, east: west + width, north: south + height };
|
||||
}
|
||||
|
||||
/** Decode a detail window of a frame (2048×1024). */
|
||||
export function loadWeatherDetail(product, time, box) {
|
||||
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=${box.west},${box.south},${box.east},${box.north}&size=2048x1024`;
|
||||
return cached(url, () => decodeFrame(url, product), true);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Raster layer ───────────────────────── */
|
||||
|
||||
class WeatherRasterLayer extends Layer {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {object} def
|
||||
* @param {{feed:object, product:string, heightM:number, alpha:number,
|
||||
* width?:number}} opts
|
||||
*/
|
||||
constructor(ctx, def, opts) {
|
||||
super(ctx, { pollMs: 2 * 60_000, ...def });
|
||||
this.feed = opts.feed;
|
||||
this.product = opts.product;
|
||||
this.heightM = opts.heightM;
|
||||
this.alpha = opts.alpha;
|
||||
this.width = opts.width ?? 2048;
|
||||
this.primitive = null;
|
||||
this.material = null;
|
||||
this.frame = null;
|
||||
this._want = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
await this._apply();
|
||||
if (this.frame && this.feed.data)
|
||||
this.message = `${this.feed.data.title} · ${relTime(this.frameTime, Date.now())}`;
|
||||
else if (this.feed.data?.coverage) this.message ||= this.feed.data.coverage;
|
||||
}
|
||||
|
||||
_ensurePrimitive(bounds) {
|
||||
if (this.primitive) return;
|
||||
this.material = Cesium.Material.fromType('Image', {
|
||||
image: Cesium.Material.DefaultImageId,
|
||||
color: new Cesium.Color(1, 1, 1, this.alpha),
|
||||
});
|
||||
const rect = Cesium.Rectangle.fromDegrees(
|
||||
bounds.west,
|
||||
bounds.south,
|
||||
bounds.east,
|
||||
bounds.north,
|
||||
);
|
||||
this.primitive = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: rect,
|
||||
height: this.heightM,
|
||||
granularity: Cesium.Math.toRadians(1),
|
||||
vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.EllipsoidSurfaceAppearance({
|
||||
material: this.material,
|
||||
flat: true,
|
||||
aboveGround: true,
|
||||
translucent: true,
|
||||
renderState: { depthMask: false },
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async _apply() {
|
||||
const data = this.feed.data;
|
||||
if (!data) return;
|
||||
const time = frameAt(
|
||||
data.times,
|
||||
this.ctx.isLive() ? null : this.ctx.timeMs(),
|
||||
);
|
||||
this._want = time;
|
||||
if (!time) {
|
||||
if (this.primitive) this.primitive.show = false;
|
||||
this.count = 0;
|
||||
this.message = 'No observation at this time';
|
||||
this.ctx.requestRender();
|
||||
return;
|
||||
}
|
||||
if (time === this.frame) {
|
||||
if (this.primitive) this.primitive.show = true;
|
||||
return;
|
||||
}
|
||||
this._ensurePrimitive(data.bounds);
|
||||
const canvas = await loadWeatherFrame(this.product, time, this.width);
|
||||
if (this._want !== time || !this.on) return;
|
||||
this.material.uniforms.image = canvas;
|
||||
this.primitive.show = true;
|
||||
this.frame = time;
|
||||
this.frameCanvas = canvas;
|
||||
this.frameTime = Date.parse(time);
|
||||
this.message = `${data.title} · ${relTime(this.frameTime, Date.now())}`;
|
||||
if (this.afterFrame) this.afterFrame(canvas, data);
|
||||
else this.count = 1;
|
||||
this._detail().catch(() => {});
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
/** Close in, a sharper window of the same frame is laid over the global one. */
|
||||
onView(view) {
|
||||
this.lastView = view;
|
||||
if (!this.on) return;
|
||||
clearTimeout(this._detailTimer);
|
||||
this._detailTimer = setTimeout(
|
||||
() => this._detail().catch((e) => console.warn(`[globe:${this.id}]`, e)),
|
||||
350,
|
||||
);
|
||||
}
|
||||
|
||||
async _detail() {
|
||||
const v = this.lastView;
|
||||
const data = this.feed.data;
|
||||
const box =
|
||||
v && v.heightM < 3_500_000 && this.frame && data
|
||||
? detailWindow(v.rect, data.bounds)
|
||||
: null;
|
||||
if (!box) {
|
||||
if (this.detailPrim) this.detailPrim.show = false;
|
||||
this._detailKey = null;
|
||||
this.ctx.requestRender();
|
||||
return;
|
||||
}
|
||||
const key = `${this.frame}|${box.west},${box.south},${box.east},${box.north}`;
|
||||
if (key === this._detailKey) {
|
||||
if (this.detailPrim) this.detailPrim.show = true;
|
||||
return;
|
||||
}
|
||||
this._detailKey = key;
|
||||
const canvas = await loadWeatherDetail(this.product, this.frame, box);
|
||||
if (this._detailKey !== key || !this.on) return;
|
||||
if (this.detailPrim) this.root.remove(this.detailPrim);
|
||||
const material = Cesium.Material.fromType('Image', {
|
||||
image: canvas,
|
||||
color: new Cesium.Color(1, 1, 1, this.alpha),
|
||||
});
|
||||
this.detailPrim = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
),
|
||||
height: this.heightM + 60,
|
||||
granularity: Cesium.Math.toRadians(0.25),
|
||||
vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.EllipsoidSurfaceAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
aboveGround: true,
|
||||
translucent: true,
|
||||
renderState: { depthMask: false },
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
}),
|
||||
);
|
||||
// The coarse global frame stays underneath only where the window ends.
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
onTime() {
|
||||
if (!this.on) return;
|
||||
clearTimeout(this._timeTimer);
|
||||
// Frames are whole-extent images: decode at most ~4 per second while
|
||||
// the ribbon is being dragged.
|
||||
this._timeTimer = setTimeout(
|
||||
() => this._apply().catch((e) => console.warn(`[globe:${this.id}]`, e)),
|
||||
250,
|
||||
);
|
||||
}
|
||||
|
||||
onHide() {
|
||||
if (this.primitive) this.primitive.show = false;
|
||||
if (this.detailPrim) this.detailPrim.show = false;
|
||||
this._detailKey = null;
|
||||
this.frame = null;
|
||||
}
|
||||
|
||||
info() {
|
||||
const i = super.info();
|
||||
if (this.on && this.feed.data) i.frames = this.feed.data.times.length;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Lightning ───────────────────────── */
|
||||
|
||||
const STRIKE_BIN_DEG = 0.5;
|
||||
const STRIKE_CAP = 1500;
|
||||
|
||||
/**
|
||||
* Strike-density raster plus a glow per active 0.5° cell, so storms read as
|
||||
* flickering points of light from orbit. nowCOAST's palette runs yellow →
|
||||
* orange → red → magenta with density; the glow grades the same way.
|
||||
*/
|
||||
class LightningLayer extends WeatherRasterLayer {
|
||||
constructor(ctx, opts) {
|
||||
super(
|
||||
ctx,
|
||||
{ id: 'lightning', family: 'Weather', name: 'Lightning' },
|
||||
{ ...opts, product: 'lightning', heightM: 9000, alpha: 0.95 },
|
||||
);
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#FFF2A8'),
|
||||
glyphName: 'bolt',
|
||||
rotate: false,
|
||||
lodHeightM: 1_500_000,
|
||||
cap: 300,
|
||||
dotScale: 0.55,
|
||||
glyphScale: 0.42,
|
||||
dotAlpha: 1,
|
||||
});
|
||||
this.cells = new Map();
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
super.onView(view);
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
|
||||
afterFrame(canvas, data) {
|
||||
const cells = strikeCells(canvas, data.bounds);
|
||||
this.cells = new Map(cells.map((c) => [c.key, c]));
|
||||
this.count = cells.length;
|
||||
this.renderer.setItems(
|
||||
cells.map((c) => ({
|
||||
key: c.key,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
altM: 9500,
|
||||
color: color(
|
||||
c.level >= 3 ? '#FF8AE6' : c.level === 2 ? '#FFD27A' : '#FFF6C8',
|
||||
),
|
||||
dotScale: 0.32 + Math.min(0.55, c.pixels / 12),
|
||||
glyphScale: 0.8 + c.level * 0.12,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 Hz flicker: a few cells flare each second (cheap, no render loop). */
|
||||
onTick() {
|
||||
const n = this.renderer.dots.length;
|
||||
if (!n) return false;
|
||||
for (let k = 0; k < Math.min(24, n); k++) {
|
||||
const i = Math.floor(Math.random() * n);
|
||||
const bb = this.renderer.dots.get(i);
|
||||
bb.scale = bb._base ??= bb.scale;
|
||||
bb.scale = bb._base * (1.4 + Math.random() * 0.9);
|
||||
setTimeout(() => {
|
||||
if (this.renderer.dots.contains(bb)) bb.scale = bb._base;
|
||||
}, 220);
|
||||
}
|
||||
setTimeout(() => this.ctx.requestRender(), 230);
|
||||
return true;
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const c = this.cells.get(key);
|
||||
return c ? [c.lon, c.lat, 9500] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const c = this.cells.get(key);
|
||||
if (!c) return null;
|
||||
return {
|
||||
kind: 'lightning',
|
||||
id: key,
|
||||
title: 'Lightning',
|
||||
subtitle: `Strike density class ${c.level} · ${relTime(this.frameTime, Date.now())}`,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
meta: { layer: this.id, level: c.level, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a nowCOAST lightning-density pixel 1 (low) .. 4 (extreme). */
|
||||
export function strikeLevel(r, g, b) {
|
||||
if (r > 200 && g > 200 && b < 140) return 1; // yellow
|
||||
if (r > 200 && g > 90) return 2; // orange
|
||||
if (r > 170 && b > 120) return 4; // magenta / pink
|
||||
if (r > 150) return 3; // red
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Bin the lit pixels of a whole-extent lightning frame into 0.5° cells. */
|
||||
export function strikeCells(canvas, bounds, bbox = null) {
|
||||
const g = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const { width, height } = canvas;
|
||||
const px = g.getImageData(0, 0, width, height).data;
|
||||
const dLon = (bounds.east - bounds.west) / width;
|
||||
const dLat = (bounds.north - bounds.south) / height;
|
||||
const bins = new Map();
|
||||
for (let y = 0; y < height; y++) {
|
||||
const lat = bounds.north - (y + 0.5) * dLat;
|
||||
if (bbox && (lat < bbox.south || lat > bbox.north)) continue;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
if (px[i + 3] < 40) continue;
|
||||
const lon = bounds.west + (x + 0.5) * dLon;
|
||||
if (bbox && (lon < bbox.west || lon > bbox.east)) continue;
|
||||
const level = strikeLevel(px[i], px[i + 1], px[i + 2]);
|
||||
const key = `${Math.floor(lat / STRIKE_BIN_DEG)}:${Math.floor(lon / STRIKE_BIN_DEG)}`;
|
||||
let c = bins.get(key);
|
||||
if (!c)
|
||||
bins.set(
|
||||
key,
|
||||
(c = { key, lat: 0, lon: 0, pixels: 0, level: 0, weight: 0 }),
|
||||
);
|
||||
c.lat += lat;
|
||||
c.lon += lon;
|
||||
c.pixels++;
|
||||
c.weight += level;
|
||||
c.level = Math.max(c.level, level);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const c of bins.values()) {
|
||||
c.lat /= c.pixels;
|
||||
c.lon /= c.pixels;
|
||||
out.push(c);
|
||||
}
|
||||
out.sort((a, b) => b.weight - a.weight);
|
||||
return out.slice(0, STRIKE_CAP);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Wind (GPU streamlines) ───────────────────────── */
|
||||
|
||||
class WindLayer extends Layer {
|
||||
constructor(ctx, def, { feed }) {
|
||||
super(ctx, { pollMs: 60 * 60_000, ...def });
|
||||
this.feed = feed;
|
||||
this.gpu = createWindGpuRendering({
|
||||
cesium: Cesium,
|
||||
getViewer: () => ctx.viewer,
|
||||
});
|
||||
this.field = null;
|
||||
this.started = performance.now();
|
||||
this._raf = 0;
|
||||
this._lastFrame = 0;
|
||||
const scene = ctx.scene;
|
||||
this._pre = scene.preRender.addEventListener(() => {
|
||||
if (!this.on || !this.field) return;
|
||||
this.gpu.tick((performance.now() - this.started) / 1000);
|
||||
this.gpu.updateVisibility(scene.camera);
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
const d = this.feed.data;
|
||||
if (!d) return;
|
||||
if (d.field !== this.field) {
|
||||
this.field = d.field;
|
||||
if (this.on) this.gpu.setField(this.field);
|
||||
}
|
||||
this.count = this.gpu.getParticleCount();
|
||||
this.message = `${d.level} · ${d.source}`;
|
||||
this._loop();
|
||||
}
|
||||
|
||||
setOn(on) {
|
||||
super.setOn(on);
|
||||
if (on && this.field) {
|
||||
this.gpu.setField(this.field);
|
||||
this._loop();
|
||||
}
|
||||
}
|
||||
|
||||
onHide() {
|
||||
this.gpu.clear();
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._raf = 0;
|
||||
}
|
||||
|
||||
/** Flowing streaks need frames: ~30 fps while on and visible, 0 otherwise. */
|
||||
_loop() {
|
||||
if (this._raf || !this.on) return;
|
||||
const step = (t) => {
|
||||
this._raf = 0;
|
||||
if (!this.on) return;
|
||||
if (this.ctx.active() && t - this._lastFrame > 33) {
|
||||
this._lastFrame = t;
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
this._raf = requestAnimationFrame(step);
|
||||
};
|
||||
this._raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._pre?.();
|
||||
this.gpu.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Air quality ───────────────────────── */
|
||||
|
||||
class AirQualityLayer extends Layer {
|
||||
constructor(ctx, { feed }) {
|
||||
super(ctx, {
|
||||
id: 'air-quality',
|
||||
family: 'Hazards',
|
||||
name: 'Air quality',
|
||||
pollMs: 60 * 60_000,
|
||||
});
|
||||
this.feed = feed;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#F2D33B'),
|
||||
glyphName: 'aq',
|
||||
rotate: false,
|
||||
alwaysGlyph: true,
|
||||
dotScale: 0.9,
|
||||
glyphScale: 0.5,
|
||||
});
|
||||
this.records = new Map();
|
||||
}
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
const recs = this.feed.data.records;
|
||||
this.records = new Map(recs.map((r) => [r.id, r]));
|
||||
this.count = recs.length;
|
||||
this.renderer.setItems(
|
||||
recs.map((r) => ({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
color: color(aqiColor(r.aqi)),
|
||||
dotScale: 0.8 + Math.min(2, r.aqi / 100),
|
||||
glyphScale: 0.55 + Math.min(0.6, r.aqi / 300),
|
||||
})),
|
||||
);
|
||||
}
|
||||
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: 'air-quality',
|
||||
id: r.id,
|
||||
title: `${r.name} · AQI ${Math.round(r.aqi)}`,
|
||||
subtitle: `PM2.5 ${r.pm25?.toFixed(0) ?? '–'} µg/m³ · modelled (CAMS)`,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
labelDetail: `AQI ${Math.round(r.aqi)}`,
|
||||
meta: { layer: this.id, aqi: r.aqi, pm25: r.pm25, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── GNSS interference ───────────────────────── */
|
||||
|
||||
const GNSS_COLORS = { high: '#FF3B6B', medium: '#FFB13D', low: '#4ADE80' };
|
||||
|
||||
class GnssLayer extends Layer {
|
||||
constructor(ctx) {
|
||||
super(ctx, {
|
||||
id: 'gnss',
|
||||
family: 'Infra',
|
||||
name: 'GNSS interference',
|
||||
pollMs: 2 * 60_000,
|
||||
});
|
||||
this.source = 'adsb.lol NIC/NACp (gpsjam.org bands)';
|
||||
this.cellsPrim = null;
|
||||
this.anchor = null;
|
||||
this.cells = new Map();
|
||||
}
|
||||
|
||||
_anchors() {
|
||||
const v = this.lastView;
|
||||
if (v && v.heightM < 3_000_000 && v.center)
|
||||
return [{ id: 'view', lat: v.center.lat, lon: v.center.lon }];
|
||||
return GNSS_ANCHORS;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const anchors = this._anchors();
|
||||
const results = await Promise.allSettled(
|
||||
anchors.map((a) =>
|
||||
gnssIntegrity.get(`${Math.round(a.lat)}:${Math.round(a.lon)}`, a),
|
||||
),
|
||||
);
|
||||
const ok = results
|
||||
.filter((r) => r.status === 'fulfilled')
|
||||
.map((r) => r.value);
|
||||
if (!ok.length) {
|
||||
this.status = 'error';
|
||||
this.message =
|
||||
results[0]?.reason?.message || 'GNSS integrity source unavailable';
|
||||
return;
|
||||
}
|
||||
this.status = 'live';
|
||||
this.message = 'Low NIC/NACp is a proxy for jamming or spoofing, not proof';
|
||||
const cells = new Map();
|
||||
for (const snap of ok)
|
||||
for (const c of snap.cells)
|
||||
cells.set(`${c.lat.toFixed(2)}:${c.lon.toFixed(2)}`, c);
|
||||
this.cells = cells;
|
||||
this._draw();
|
||||
}
|
||||
|
||||
_draw() {
|
||||
if (this.cellsPrim) this.root.remove(this.cellsPrim);
|
||||
this.cellsPrim = null;
|
||||
const shown = [...this.cells.entries()].filter(
|
||||
([, c]) => c.level !== 'low' || c.total >= 6,
|
||||
);
|
||||
this.count = shown.filter(([, c]) => c.level !== 'low').length;
|
||||
if (!shown.length) return;
|
||||
const instances = shown.map(([key, c]) => {
|
||||
const h = 0.25;
|
||||
return new Cesium.GeometryInstance({
|
||||
id: { layer: this.id, key },
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
c.lon - h,
|
||||
c.lat - h,
|
||||
c.lon + h,
|
||||
c.lat + h,
|
||||
),
|
||||
height: 2000,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color(GNSS_COLORS[c.level], c.level === 'low' ? 0.18 : 0.5),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
this.cellsPrim = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
}),
|
||||
);
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
const prev = this.lastView;
|
||||
this.lastView = view;
|
||||
const moved =
|
||||
!prev ||
|
||||
view.heightM < 3_000_000 !== prev.heightM < 3_000_000 ||
|
||||
(view.heightM < 3_000_000 &&
|
||||
haversineKm(
|
||||
view.center.lat,
|
||||
view.center.lon,
|
||||
prev.center.lat,
|
||||
prev.center.lon,
|
||||
) > 150);
|
||||
if (moved && this.on) this.kick();
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const c = this.cells.get(key);
|
||||
return c ? [c.lon, c.lat, 0] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const c = this.cells.get(key);
|
||||
if (!c) return null;
|
||||
return {
|
||||
kind: 'gnss',
|
||||
id: key,
|
||||
title: `GNSS ${c.level === 'high' ? 'interference likely' : c.level === 'medium' ? 'degraded' : 'nominal'}`,
|
||||
subtitle: `${c.bad}/${c.total} aircraft with low NIC/NACp · ${c.percent.toFixed(0)}%`,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
meta: { layer: this.id, ...c, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Transit ───────────────────────── */
|
||||
|
||||
function routeColor(routeId) {
|
||||
let h = 0;
|
||||
for (const ch of String(routeId)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return `hsl(${h % 360} 85% 62%)`;
|
||||
}
|
||||
|
||||
class TransitLayer extends Layer {
|
||||
constructor(ctx, { catalog }) {
|
||||
super(ctx, {
|
||||
id: 'transit',
|
||||
family: 'Ground',
|
||||
name: 'Transit vehicles',
|
||||
pollMs: 20_000,
|
||||
});
|
||||
this.catalog = catalog;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(FAMILY_COLORS.Ground),
|
||||
glyphName: 'bus',
|
||||
lodHeightM: 400_000,
|
||||
cap: 600,
|
||||
dotScale: 0.32,
|
||||
glyphScale: 0.38,
|
||||
});
|
||||
this.records = new Map();
|
||||
this.feedId = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
let cat;
|
||||
try {
|
||||
cat = await this.catalog.get();
|
||||
} finally {
|
||||
this.fromFeed(this.catalog);
|
||||
}
|
||||
const c = this.lastView?.center;
|
||||
const feeds = cat.feeds;
|
||||
let best = null;
|
||||
for (const f of feeds) {
|
||||
const d = c ? haversineKm(c.lat, c.lon, f.center.lat, f.center.lon) : 0;
|
||||
if (!best || d - f.loadRadiusKm < best.d - best.f.loadRadiusKm)
|
||||
best = { f, d };
|
||||
}
|
||||
if (
|
||||
!best ||
|
||||
(c && best.d > best.f.loadRadiusKm + 400 && this.lastView?.heightM < 3e6)
|
||||
) {
|
||||
this.count = 0;
|
||||
this.message = 'No open transit feed near this view';
|
||||
this.renderer.clear();
|
||||
return;
|
||||
}
|
||||
// From orbit the nearest feed is arbitrary: show Helsinki's (busy, CC BY).
|
||||
const feed =
|
||||
this.lastView?.heightM >= 3e6
|
||||
? feeds.find((f) => f.id === 'hsl-helsinki') || best.f
|
||||
: best.f;
|
||||
this.feedId = feed.id;
|
||||
const snap = await transitVehicles.get(feed.id);
|
||||
this.source = `${feed.name} · ${feed.attribution}`;
|
||||
this.message = `${feed.region} · ${snap.vehicles.length} vehicles`;
|
||||
this.status = 'live';
|
||||
this.records = new Map(snap.vehicles.map((v) => [v.id, v]));
|
||||
this.count = snap.vehicles.length;
|
||||
this.renderer.setItems(
|
||||
snap.vehicles.map((v) => ({
|
||||
key: v.id,
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
heading: v.bearing ?? 0,
|
||||
color: color(routeColor(v.routeId)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
const prev = this.lastView;
|
||||
this.lastView = view;
|
||||
this.renderer.setView(view);
|
||||
if (
|
||||
this.on &&
|
||||
prev?.center &&
|
||||
haversineKm(
|
||||
view.center.lat,
|
||||
view.center.lon,
|
||||
prev.center.lat,
|
||||
prev.center.lon,
|
||||
) > 120
|
||||
)
|
||||
this.kick();
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const v = this.records.get(key);
|
||||
return v ? [v.lon, v.lat, 0] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const v = this.records.get(key);
|
||||
if (!v) return null;
|
||||
return {
|
||||
kind: 'transit',
|
||||
id: v.id,
|
||||
title: `Route ${v.routeId || '?'}`,
|
||||
subtitle: [
|
||||
v.speedMps != null ? `${Math.round(v.speedMps * 3.6)} km/h` : null,
|
||||
v.status ? v.status.replace(/_/g, ' ').toLowerCase() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
meta: { layer: this.id, feedId: this.feedId, source: this.source },
|
||||
};
|
||||
}
|
||||
|
||||
contacts() {
|
||||
return [...this.records.values()].map((v) => ({
|
||||
kind: 'transit',
|
||||
id: v.id,
|
||||
title: `Route ${v.routeId}`,
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
altM: 0,
|
||||
headingDeg: v.bearing ?? 0,
|
||||
speedMps: v.speedMps,
|
||||
layer: this.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export { inRect };
|
||||
|
||||
export function createWeatherLayers(ctx, feeds) {
|
||||
return [
|
||||
new WeatherRasterLayer(
|
||||
ctx,
|
||||
{ id: 'radar', family: 'Weather', name: 'Radar' },
|
||||
{ feed: feeds.radar, product: 'radar', heightM: 4000, alpha: 0.9 },
|
||||
),
|
||||
new WeatherRasterLayer(
|
||||
ctx,
|
||||
{
|
||||
id: 'clouds',
|
||||
family: 'Weather',
|
||||
name: 'Clouds (satellite IR)',
|
||||
pollMs: 10 * 60_000,
|
||||
},
|
||||
{ feed: feeds.clouds, product: 'clouds', heightM: 7000, alpha: 0.85 },
|
||||
),
|
||||
new LightningLayer(ctx, { feed: feeds.lightning, width: 2048 }),
|
||||
new WindLayer(
|
||||
ctx,
|
||||
{ id: 'wind', family: 'Weather', name: 'Wind (10 m)' },
|
||||
{ feed: feeds.wind },
|
||||
),
|
||||
new WindLayer(
|
||||
ctx,
|
||||
{ id: 'jet', family: 'Weather', name: 'Jet stream (250 hPa)' },
|
||||
{ feed: feeds.jet },
|
||||
),
|
||||
new AirQualityLayer(ctx, { feed: feeds.airQuality }),
|
||||
new GnssLayer(ctx),
|
||||
new TransitLayer(ctx, { catalog: feeds.transitCatalog }),
|
||||
];
|
||||
}
|
||||
|
||||
export { WX };
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Pins for discourse and capture (DESIGN.md §11.3–11.4): posts, sealed
|
||||
* witness captures, public camera eyes, live broadcasts and holograms. Eyes
|
||||
* and live pins draw their view frustum as a translucent wedge on the ground
|
||||
* along the camera heading. Pins are few (tens), so the wedge primitive is
|
||||
* simply rebuilt on change.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { glyph } from './glyphs.js';
|
||||
import { offsetLonLat } from './geo.js';
|
||||
|
||||
export const PIN_KINDS = Object.freeze([
|
||||
'post',
|
||||
'witness',
|
||||
'eye',
|
||||
'live',
|
||||
'hologram',
|
||||
]);
|
||||
|
||||
const PIN_COLORS = Object.freeze({
|
||||
post: '#E8EEF7',
|
||||
witness: '#FFC857',
|
||||
eye: '#FFD166',
|
||||
live: '#FF4D6D',
|
||||
hologram: '#C9A7FF',
|
||||
});
|
||||
|
||||
const PIN_TITLES = Object.freeze({
|
||||
post: 'Post',
|
||||
witness: 'Sealed witness capture',
|
||||
eye: 'Public camera',
|
||||
live: 'Live broadcast',
|
||||
hologram: 'Hologram',
|
||||
});
|
||||
|
||||
export function createPins({ scene, requestRender }) {
|
||||
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
const billboards = root.add(new Cesium.BillboardCollection());
|
||||
const edges = root.add(new Cesium.PolylineCollection());
|
||||
let wedges = null;
|
||||
const pins = new Map();
|
||||
let pulse = 0;
|
||||
|
||||
function rebuildWedges() {
|
||||
if (wedges) root.remove(wedges);
|
||||
wedges = null;
|
||||
edges.removeAll();
|
||||
const instances = [];
|
||||
for (const p of pins.values()) {
|
||||
if (p.kind !== 'eye' && p.kind !== 'live') continue;
|
||||
if (!Number.isFinite(p.headingDeg)) continue;
|
||||
const half = (p.fovDeg ?? 60) / 2;
|
||||
const ring = [[p.lon, p.lat]];
|
||||
for (let a = -half; a <= half + 0.01; a += Math.max(2, half / 6))
|
||||
ring.push(offsetLonLat(p.lon, p.lat, p.headingDeg + a, p.rangeM));
|
||||
const css = p.color || PIN_COLORS[p.kind];
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: { layer: 'pins', key: p.id },
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(ring.flat()),
|
||||
),
|
||||
height: (p.altM || 0) + 3,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.fromCssColorString(css).withAlpha(0.28),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
edges.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(
|
||||
[...ring, ring[0]].flatMap(([lo, la]) => [lo, la, (p.altM || 0) + 4]),
|
||||
),
|
||||
width: 1.5,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.fromCssColorString(css).withAlpha(0.85),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (instances.length)
|
||||
wedges = root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rebuildBillboards() {
|
||||
billboards.removeAll();
|
||||
for (const p of pins.values()) {
|
||||
const g = glyph(p.kind);
|
||||
p._bb = billboards.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
p.lon,
|
||||
p.lat,
|
||||
(p.altM || 0) + 8,
|
||||
),
|
||||
image: g.image,
|
||||
imageId: g.imageId,
|
||||
color: Cesium.Color.fromCssColorString(p.color || PIN_COLORS[p.kind]),
|
||||
scale: 0.62,
|
||||
scaleByDistance: new Cesium.NearFarScalar(2e3, 1.15, 8e6, 0.55),
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
disableDepthTestDistance: 2e5,
|
||||
id: { layer: 'pins', key: p.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function changed() {
|
||||
rebuildBillboards();
|
||||
rebuildWedges();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
return {
|
||||
add(pin) {
|
||||
if (!pin || pin.id == null) throw new TypeError('addPin needs an id');
|
||||
if (!Number.isFinite(pin.lon) || !Number.isFinite(pin.lat))
|
||||
throw new TypeError('addPin needs lon and lat');
|
||||
const kind = PIN_KINDS.includes(pin.kind) ? pin.kind : 'post';
|
||||
const p = {
|
||||
id: String(pin.id),
|
||||
lon: pin.lon,
|
||||
lat: pin.lat,
|
||||
kind,
|
||||
headingDeg: Number.isFinite(pin.headingDeg) ? pin.headingDeg : null,
|
||||
fovDeg: Number.isFinite(pin.fovDeg) ? pin.fovDeg : 60,
|
||||
rangeM: Number.isFinite(pin.rangeM) ? pin.rangeM : 350,
|
||||
color: pin.color || null,
|
||||
label: pin.label || '',
|
||||
altM: Number.isFinite(pin.altM) ? pin.altM : 0,
|
||||
};
|
||||
pins.set(p.id, p);
|
||||
changed();
|
||||
return p.id;
|
||||
},
|
||||
remove(id) {
|
||||
const ok = pins.delete(String(id));
|
||||
if (ok) changed();
|
||||
return ok;
|
||||
},
|
||||
list() {
|
||||
return [...pins.values()].map(({ _bb, ...p }) => ({ ...p }));
|
||||
},
|
||||
locate(id) {
|
||||
const p = pins.get(String(id));
|
||||
return p ? [p.lon, p.lat, p.altM || 0] : null;
|
||||
},
|
||||
pick(id) {
|
||||
const p = pins.get(String(id));
|
||||
if (!p) return null;
|
||||
return {
|
||||
kind: 'pin',
|
||||
pinKind: p.kind,
|
||||
id: p.id,
|
||||
title: p.label || PIN_TITLES[p.kind],
|
||||
subtitle: PIN_TITLES[p.kind],
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
meta: {
|
||||
layer: 'pins',
|
||||
headingDeg: p.headingDeg,
|
||||
fovDeg: p.fovDeg,
|
||||
label: p.label,
|
||||
},
|
||||
};
|
||||
},
|
||||
/** Live pins breathe once a second; returns true when anything changed. */
|
||||
tick() {
|
||||
let any = false;
|
||||
pulse = (pulse + 1) % 2;
|
||||
for (const p of pins.values())
|
||||
if (p.kind === 'live' && p._bb) {
|
||||
p._bb.scale = pulse ? 0.74 : 0.6;
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
},
|
||||
get billboards() {
|
||||
return billboards;
|
||||
},
|
||||
destroy() {
|
||||
scene.primitives.remove(root);
|
||||
},
|
||||
};
|
||||
}
|
||||
+1821
-388
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user