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,