feat(mobile): weather and storm layers, card compositor, pins and nearest eye

Radar (nowCOAST, with replay), satellite IR clouds, lightning density,
NHC cones and 34/50/64-kt wind radii, 10 m and 250 hPa wind on the
repo's GPU renderer, air quality, GNSS interference and transit.

situations() now yields 25-30 cards across orbit, sky and ground with
band, archetype, telemetry, headline, real series and live camera media;
genres whose feed fails are omitted and reported by omitted().
thumbnail() composites each card in an offscreen canvas from the layers
that describe it. addPin() draws posts, witnesses and live view
frustums; nearestEye() finds the closest public camera.

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:57:24 +00:00
1 parent a644e797a7
commit 2ef9dd7448
6 files changed
+73 -9

No files matched your search

+10
View File
@@ -101,3 +101,13 @@ globe.eyeFrameUrl(cameraId) → string | null // a fresh same-origin frame URL
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`. 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. 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.
When NHC has published an advisory but its cone geometry is still pending, the cyclone layer and cards take the forecast positions from the advisory text, so a track is still drawn but there is no cone.
## Dev harnesses
- `mobile/globe/dev.html` shows the globe full-screen and exposes `window.globe`. Its labels show `detail`.
- `mobile/globe/contact.html` is the contact sheet: it calls `situations()`, then `thumbnail()` for every card.
- `?lat=&lon=` sets the viewer.
- `?only=cyclone,eye` filters cards by id prefix.
- `?w=330` sets the card width.
+7 -7
View File
@@ -685,12 +685,12 @@ const SCENE_DRAW = {
); );
const box = bb || sc.bounds; const box = bb || sc.bounds;
const colors = [ const colors = [
'rgba(255,250,225,1)', 'rgba(255,255,255,1)',
'rgba(255,214,120,0.85)', 'rgba(255,226,122,0.75)',
'rgba(255,150,80,0.7)', 'rgba(255,150,70,0.6)',
'rgba(240,90,160,0.55)', 'rgba(255,85,150,0.5)',
'rgba(170,90,255,0.45)', 'rgba(176,107,255,0.42)',
'rgba(110,90,255,0.35)', 'rgba(106,107,255,0.34)',
]; ];
// Sample at the product's native ~8 km grid, one dot per cell. // Sample at the product's native ~8 km grid, one dot per cell.
const kmPerPx = const kmPerPx =
@@ -722,7 +722,7 @@ const SCENE_DRAW = {
continue; continue;
const lvl = strikeLevel(px[k], px[k + 1], px[k + 2]); const lvl = strikeLevel(px[k], px[k + 1], px[k + 2]);
const r = const r =
dotR * (age === 0 ? 1.9 : 1.1 - age * 0.08) * (0.7 + lvl * 0.3); dotR * (age === 0 ? 0.85 : 1.6 - age * 0.1) * (0.75 + lvl * 0.2);
g.drawImage(sprite, sx - r, sy - r, r * 2, r * 2); g.drawImage(sprite, sx - r, sy - r, r * 2, r * 2);
} }
}); });
+14
View File
@@ -418,6 +418,19 @@ export function parseTcm(text) {
quadrantsKm: [m[2], m[3], m[4], m[5]].map((x) => Number(x) * NM_KM), quadrantsKm: [m[2], m[3], m[4], m[5]].map((x) => Number(x) * NM_KM),
}); });
} }
// Forecast and outlook positions (used when the cone geometry is pending).
const forecast = [];
for (const m of text.matchAll(
/(?:FORECAST|OUTLOOK) VALID\s+(\d\d)\/(\d\d)(\d\d)Z\s+(\d+\.\d)([NS])\s+(\d+\.\d)([EW])[\s\S]*?MAX WIND\s+(\d+) KT/g,
)) {
forecast.push({
day: Number(m[1]),
hourZ: Number(m[2]),
lat: Number(m[4]) * (m[5] === 'S' ? -1 : 1),
lon: Number(m[6]) * (m[7] === 'W' ? -1 : 1),
windKt: Number(m[8]),
});
}
const pressure = /MINIMUM CENTRAL PRESSURE\s+(\d+) MB/.exec(text)?.[1]; const pressure = /MINIMUM CENTRAL PRESSURE\s+(\d+) MB/.exec(text)?.[1];
const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1]; const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
return { return {
@@ -425,6 +438,7 @@ export function parseTcm(text) {
lat, lat,
lon, lon,
radii, radii,
forecast,
pressureHpa: pressure ? Number(pressure) : null, pressureHpa: pressure ? Number(pressure) : null,
windKt: maxWind ? Number(maxWind) : null, windKt: maxWind ? Number(maxWind) : null,
}; };
+2
View File
@@ -969,6 +969,7 @@ export async function createGlobe(container, options = {}) {
// Semantic zoom for words too: from orbit only the headline layers. // Semantic zoom for words too: from orbit only the headline layers.
if (planetary && !PLANETARY_LABELS.has(layer.id)) continue; if (planetary && !PLANETARY_LABELS.has(layer.id)) continue;
if (layer.id === 'lightning') continue; // the glow is the label if (layer.id === 'lightning') continue; // the glow is the label
const crowd = layer.id === 'transit'; // hundreds of buses: selected only
const rank = LABEL_PRIORITY.indexOf(layer.id); const rank = LABEL_PRIORITY.indexOf(layer.id);
for (let i = 0; i < glyphs.length; i++) { for (let i = 0; i < glyphs.length; i++) {
const bb = glyphs.get(i); const bb = glyphs.get(i);
@@ -977,6 +978,7 @@ export async function createGlobe(container, options = {}) {
if (!p || p.x < 0 || p.y < 0 || p.x > w || p.y > h) continue; if (!p || p.x < 0 || p.y < 0 || p.x > w || p.y > h) continue;
const isSelected = const isSelected =
selected?.layer === layer.id && selected?.key === bb.id.key; selected?.layer === layer.id && selected?.key === bb.id.key;
if (crowd && !isSelected) continue;
const d = Math.hypot(p.x - w / 2, p.y - h / 2) / Math.max(w, h); const d = Math.hypot(p.x - w / 2, p.y - h / 2) / Math.max(w, h);
candidates.push({ candidates.push({
layer, layer,
+28
View File
@@ -345,6 +345,7 @@ class CycloneLayer extends Layer {
if (this.fills) this.root.remove(this.fills); if (this.fills) this.root.remove(this.fills);
this.fills = null; this.fills = null;
const instances = []; const instances = [];
let relined = false;
const poly = (ring, css, alpha, height) => { const poly = (ring, css, alpha, height) => {
if (!ring || ring.length < 4) return; if (!ring || ring.length < 4) return;
instances.push( instances.push(
@@ -368,6 +369,32 @@ class CycloneLayer extends Layer {
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500); poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
const tcm = radiiFor(s, this.tcm?.data); const tcm = radiiFor(s, this.tcm?.data);
if (tcm) s.radii = tcm.radii; if (tcm) s.radii = tcm.radii;
// Cone geometry pending: the advisory text still has the positions.
if (tcm?.forecast?.length && !s.forecast?.length) {
const t0 = new Date(s.t);
s.forecast = tcm.forecast.map((p) => {
let at = Date.UTC(
t0.getUTCFullYear(),
t0.getUTCMonth(),
p.day,
p.hourZ,
);
if (at < s.t - 15 * 86_400_000)
at = Date.UTC(
t0.getUTCFullYear(),
t0.getUTCMonth() + 1,
p.day,
p.hourZ,
);
return {
lon: p.lon,
lat: p.lat,
windKt: p.windKt,
tauHours: Math.round((at - s.t) / 3_600_000),
};
});
relined = true;
}
for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) { for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) {
if (!r.quadrantsKm.some((k) => k > 0)) continue; if (!r.quadrantsKm.some((k) => k > 0)) continue;
poly( poly(
@@ -378,6 +405,7 @@ class CycloneLayer extends Layer {
); );
} }
} }
if (relined) this._lines(storms);
if (!instances.length) return; if (!instances.length) return;
this.fills = this.root.add( this.fills = this.root.add(
new Cesium.Primitive({ new Cesium.Primitive({
+12 -2
View File
@@ -412,7 +412,17 @@ export async function buildSituations(engine, { observer } = {}) {
'Storm'; 'Storm';
const kmh = s.windKt ? Math.round(s.windKt * 1.852) : null; const kmh = s.windKt ? Math.round(s.windKt * 1.852) : null;
const lastTau = s.forecast?.at(-1)?.tauHours ?? 0; const lastTau = s.forecast?.at(-1)?.tauHours ?? 0;
const radii = radiiFor(s, tcm)?.radii || null; const adv = radiiFor(s, tcm);
const radii = adv?.radii || null;
// NHC sometimes publishes an advisory before its cone geometry: the
// TCM text still carries the forecast positions.
const forecast = s.forecast?.length
? s.forecast
: (adv?.forecast || []).map((p) => ({
lon: p.lon,
lat: p.lat,
windKt: p.windKt,
}));
const r34 = radii?.find((r) => r.kt === 34); const r34 = radii?.find((r) => r.kt === 34);
const cat = stormCategory(s.windKt); const cat = stormCategory(s.windKt);
return card( return card(
@@ -485,7 +495,7 @@ export async function buildSituations(engine, { observer } = {}) {
lat: s.lat, lat: s.lat,
windKt: s.windKt, windKt: s.windKt,
cone: s.cone, cone: s.cone,
forecast: s.forecast || [], forecast,
pastTrack: s.pastTrack || null, pastTrack: s.pastTrack || null,
track: s.track || null, track: s.track || null,
radii, radii,