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`.
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 colors = [
'rgba(255,250,225,1)',
'rgba(255,214,120,0.85)',
'rgba(255,150,80,0.7)',
'rgba(240,90,160,0.55)',
'rgba(170,90,255,0.45)',
'rgba(110,90,255,0.35)',
'rgba(255,255,255,1)',
'rgba(255,226,122,0.75)',
'rgba(255,150,70,0.6)',
'rgba(255,85,150,0.5)',
'rgba(176,107,255,0.42)',
'rgba(106,107,255,0.34)',
];
// Sample at the product's native ~8 km grid, one dot per cell.
const kmPerPx =
@@ -722,7 +722,7 @@ const SCENE_DRAW = {
continue;
const lvl = strikeLevel(px[k], px[k + 1], px[k + 2]);
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);
}
});
+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),
});
}
// 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 maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
return {
@@ -425,6 +438,7 @@ export function parseTcm(text) {
lat,
lon,
radii,
forecast,
pressureHpa: pressure ? Number(pressure) : 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.
if (planetary && !PLANETARY_LABELS.has(layer.id)) continue;
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);
for (let i = 0; i < glyphs.length; 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;
const isSelected =
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);
candidates.push({
layer,
+28
View File
@@ -345,6 +345,7 @@ class CycloneLayer extends Layer {
if (this.fills) this.root.remove(this.fills);
this.fills = null;
const instances = [];
let relined = false;
const poly = (ring, css, alpha, height) => {
if (!ring || ring.length < 4) return;
instances.push(
@@ -368,6 +369,32 @@ class CycloneLayer extends Layer {
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
const tcm = radiiFor(s, this.tcm?.data);
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)) {
if (!r.quadrantsKm.some((k) => k > 0)) continue;
poly(
@@ -378,6 +405,7 @@ class CycloneLayer extends Layer {
);
}
}
if (relined) this._lines(storms);
if (!instances.length) return;
this.fills = this.root.add(
new Cesium.Primitive({
+12 -2
View File
@@ -412,7 +412,17 @@ export async function buildSituations(engine, { observer } = {}) {
'Storm';
const kmh = s.windKt ? Math.round(s.windKt * 1.852) : null;
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 cat = stormCategory(s.windKt);
return card(
@@ -485,7 +495,7 @@ export async function buildSituations(engine, { observer } = {}) {
lat: s.lat,
windKt: s.windKt,
cone: s.cone,
forecast: s.forecast || [],
forecast,
pastTrack: s.pastTrack || null,
track: s.track || null,
radii,