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:
Claude committed 2026-10-19 02:49:15 +00:00
1 parent 8b7fcb09df
commit a644e797a7
36 files changed
+10296 -623

No files matched your search

+103
View File
@@ -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
+70
View File
@@ -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;
}
+16
View File
@@ -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>
+59
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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;
+716
View File
@@ -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)}&current=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}&current=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 },
);
}
+247
View File
@@ -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}`;
}
+111
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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,
+4 -1
View File
@@ -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();
+121 -3
View File
@@ -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,
+906
View File
@@ -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 };
+196
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff