From 2ef9dd74485e6cf99e387fc2741b1ed3e688f5dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:57:24 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH --- mobile/globe/API.md | 10 ++++++++++ mobile/globe/compositor.js | 14 +++++++------- mobile/globe/feedsWeather.js | 14 ++++++++++++++ mobile/globe/index.js | 2 ++ mobile/globe/layers/hazards.js | 28 ++++++++++++++++++++++++++++ mobile/globe/situations.js | 14 ++++++++++++-- 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/mobile/globe/API.md b/mobile/globe/API.md index 17bd73085..f2751534c 100644 --- a/mobile/globe/API.md +++ b/mobile/globe/API.md @@ -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. diff --git a/mobile/globe/compositor.js b/mobile/globe/compositor.js index 62ef1981b..337719928 100644 --- a/mobile/globe/compositor.js +++ b/mobile/globe/compositor.js @@ -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); } }); diff --git a/mobile/globe/feedsWeather.js b/mobile/globe/feedsWeather.js index 8951c31e9..8a87ff2c7 100644 --- a/mobile/globe/feedsWeather.js +++ b/mobile/globe/feedsWeather.js @@ -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, }; diff --git a/mobile/globe/index.js b/mobile/globe/index.js index fb0380c27..1271e3c08 100644 --- a/mobile/globe/index.js +++ b/mobile/globe/index.js @@ -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, diff --git a/mobile/globe/layers/hazards.js b/mobile/globe/layers/hazards.js index fbe724d9b..6c82cf89d 100644 --- a/mobile/globe/layers/hazards.js +++ b/mobile/globe/layers/hazards.js @@ -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({ diff --git a/mobile/globe/situations.js b/mobile/globe/situations.js index 99a8c8d03..c6fe2e6d1 100644 --- a/mobile/globe/situations.js +++ b/mobile/globe/situations.js @@ -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,