From e0b8ccf477cedffe425ef513c30b9809e83bd621 Mon Sep 17 00:00:00 2001 From: Chris Daniel Date: Wed, 8 Apr 2026 23:06:35 -0400 Subject: [PATCH 1/2] [Bug] NexRad radars seem to disconnect after an hour and are not shown on map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root Cause fetchLatest() had 5 early-return paths that didn't refresh the Redis TTL for existing scans: 1. S3 listing returns HTTP error 2. S3 listing has no contents (happens at UTC date rollover) 3. No V06/V08 volume files found 4. File timestamp parse fails 5. Station data >30 min old (stale) ← the most common trigger Since SCAN_TTL and MAX_AGE_MS are both 30 minutes, once a station's S3 data ages past 30 minutes the TTL stops being refreshed, and the scan expires from Redis within the remaining TTL window. At z8+ there's no MRMS fallback (tilers produce z2-7 only), so expired scans = transparent tiles. The previous fix (commit 4e01caf) increased TTL from 10 to 30 minutes — this delayed the symptom from ~10 min to ~30 min but didn't address the root cause. Changes src/nexrad/main.ts — Unconditionally refresh TTL for previously-ingested stations at the top of fetchLatest(), before any S3 work. This single line covers all failure paths (stale data, S3 errors, network issues, date rollover). The .catch(() => {}) prevents Redis connection errors from blocking the fetch attempt. src/server/nexrad-scan-provider.ts — Wrap readScanFromRedis in try/catch so Redis connection blips don't crash tile requests (defense-in-depth). --- src/nexrad/main.ts | 8 ++++++++ src/server/nexrad-scan-provider.ts | 16 ++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/nexrad/main.ts b/src/nexrad/main.ts index a53aeb8..227d516 100644 --- a/src/nexrad/main.ts +++ b/src/nexrad/main.ts @@ -23,6 +23,14 @@ const POLL_INTERVAL_MS = 60_000; const latestVolume = new Map(); async function fetchLatest(redis: Redis, stationId: string): Promise { + // Always refresh TTL for previously-ingested stations before attempting fetch. + // This keeps existing scans alive through ANY failure (S3 errors, stale data, + // date rollover, network issues). z8+ has no MRMS fallback — expired scans + // mean transparent tiles with no recovery until the next successful ingest. + if (latestVolume.has(stationId)) { + await refreshScanTTL(redis, stationId).catch(() => {}); + } + try { const now = new Date(); const prefix = [ diff --git a/src/server/nexrad-scan-provider.ts b/src/server/nexrad-scan-provider.ts index 2f1d20e..a9c87a6 100644 --- a/src/server/nexrad-scan-provider.ts +++ b/src/server/nexrad-scan-provider.ts @@ -42,13 +42,17 @@ export class NexradScanProvider { if (cached) return cached; if (this.missCache.has(stationId)) return null; - const scan = await readScanFromRedis(this.redis, stationId); - if (scan) { - this.scanCache.set(stationId, scan); - } else { - this.missCache.set(stationId, true); + try { + const scan = await readScanFromRedis(this.redis, stationId); + if (scan) { + this.scanCache.set(stationId, scan); + } else { + this.missCache.set(stationId, true); + } + return scan; + } catch { + return null; } - return scan; } /** Get PreparedScans for all stations covering a tile's bounds */ From 60476aed090f5835c3f257bde7f9e408e7432a04 Mon Sep 17 00:00:00 2001 From: Chris Daniel Date: Wed, 8 Apr 2026 23:28:37 -0400 Subject: [PATCH 2/2] [Bug] Zoom levels affect wind data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes across both public/index.html (the app) and landing/index.html (the homepage): 1. Replaced 375K Leaflet calls per frame with inline math The old code called map.latLngToContainerPoint() for every trail point of every particle — 15,000 particles × 25 trail points = 375,000 calls per frame. Each call created 3 Leaflet objects and did full Mercator projection math, generating ~1.1M garbage objects per frame for the GC. The fix stores trails in normalized Mercator coordinates (nx = lon/360 + 0.5, ny via a single log/tan), extracts the map transform once per frame with a single Leaflet call, then converts all trail points with just x = nx * scale + ox (multiply + add). Same visual result, ~100x less work. 2. Batched canvas draw calls by color The old code did a separate beginPath() / stroke() for each particle — 15,000 stroke() calls per frame. Each stroke() triggers rasterization. The fix bins particles by their color index, then draws each color group with a single beginPath() / stroke(). Reduces draw calls from ~15K to ~20. 3. Removed the pause/resume mechanism The old code froze the animation on movestart/zoomstart and resumed on moveend/zoomend. This caused the visible "freeze then recalculate" behavior. With the inline math being fast enough (~2-4ms per frame vs the old ~50ms), the animation now runs continuously during pan/zoom with no pause needed. --- landing/index.html | 92 +++++++++++++++++++++++------------ public/index.html | 118 +++++++++++++++++++++++++-------------------- 2 files changed, 127 insertions(+), 83 deletions(-) diff --git a/landing/index.html b/landing/index.html index f44e6e6..e53b4eb 100644 --- a/landing/index.html +++ b/landing/index.html @@ -984,6 +984,12 @@

RadrView

const W_COUNT = 12000; const W_TRAIL = 20; const W_SPEED = 0.004; + const W_DEG2RAD = Math.PI / 180; + const W_INV_2PI = 1 / (2 * Math.PI); + + function latToNormY(lat) { + return 0.5 - Math.log(Math.tan(Math.PI / 4 + lat * W_DEG2RAD / 2)) * W_INV_2PI; + } const windColors = []; for (let i = 0; i < 256; i++) { @@ -1024,8 +1030,10 @@

RadrView

}; } + const _w = { u: 0, v: 0 }; function sampleW(lon, lat) { - if (!windGrid) return { u: 0, v: 0 }; + _w.u = 0; _w.v = 0; + if (!windGrid) return _w; let gl = lon % 360; if (gl < 0) gl += 360; const xi = gl / windGrid.dx, yi = (windGrid.latMax - lat) / windGrid.dy; const x0 = Math.floor(xi), y0 = Math.floor(yi); @@ -1033,65 +1041,85 @@

RadrView

const fx = xi-x0, fy = yi-y0; const i00 = y0*windGrid.width+x0, i10 = y0*windGrid.width+x1; const i01 = y1*windGrid.width+x0, i11 = y1*windGrid.width+x1; - return { - u: windGrid.u[i00]*(1-fx)*(1-fy)+windGrid.u[i10]*fx*(1-fy)+windGrid.u[i01]*(1-fx)*fy+windGrid.u[i11]*fx*fy, - v: windGrid.v[i00]*(1-fx)*(1-fy)+windGrid.v[i10]*fx*(1-fy)+windGrid.v[i01]*(1-fx)*fy+windGrid.v[i11]*fx*fy, - }; + const w00 = (1-fx)*(1-fy), w10 = fx*(1-fy), w01 = (1-fx)*fy, w11 = fx*fy; + _w.u = windGrid.u[i00]*w00+windGrid.u[i10]*w10+windGrid.u[i01]*w01+windGrid.u[i11]*w11; + _w.v = windGrid.v[i00]*w00+windGrid.v[i10]*w10+windGrid.v[i01]*w01+windGrid.v[i11]*w11; + return _w; } - let windPaused = false; + const colorBins = new Array(256); + for (let c = 0; c < 256; c++) colorBins[c] = []; + function animateWind() { - if (!windGrid || windPaused) return; + if (!windGrid) return; const container = document.getElementById('map'); const rect = container.getBoundingClientRect(); - if (windCanvas.width !== rect.width * devicePixelRatio) { - windCanvas.width = rect.width * devicePixelRatio; - windCanvas.height = rect.height * devicePixelRatio; + const dpr = devicePixelRatio; + const cw = Math.round(rect.width * dpr); + const ch = Math.round(rect.height * dpr); + if (windCanvas.width !== cw || windCanvas.height !== ch) { + windCanvas.width = cw; + windCanvas.height = ch; windCanvas.style.width = rect.width + 'px'; windCanvas.style.height = rect.height + 'px'; } - windCtx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + windCtx.setTransform(dpr, 0, 0, dpr, 0, 0); windCtx.clearRect(0, 0, rect.width, rect.height); - windCtx.lineWidth = 0.8; + + // Extract map transform ONCE — then pure arithmetic for all 240K trail points + const center = map.getCenter(); + const ref = map.latLngToContainerPoint(center); + const scale = 256 * Math.pow(2, map.getZoom()); + const ox = ref.x - (center.lng / 360 + 0.5) * scale; + const oy = ref.y - latToNormY(center.lat) * scale; + const b = map.getBounds(); + const west = b.getWest(), east = b.getEast(); + const south = b.getSouth(), north = b.getNorth(); + + for (let c = 0; c < 256; c++) colorBins[c].length = 0; for (let i = 0; i < windParticles.length; i++) { const p = windParticles[i]; p.age++; if (p.age > p.maxAge || p.lat < -85 || p.lat > 85) { - windParticles[i] = spawnWP(); continue; + windParticles[i] = { + lon: west + Math.random() * (east - west), + lat: south + Math.random() * (north - south), + trail: [], age: 0, maxAge: 150 + Math.floor(Math.random() * 100), + }; + continue; } const w = sampleW(p.lon, p.lat); const spd = Math.sqrt(w.u*w.u + w.v*w.v); - const cl = Math.cos(p.lat * Math.PI / 180); + const cl = Math.cos(p.lat * W_DEG2RAD); p.lon += (w.u * W_SPEED) / Math.max(cl, 0.1); p.lat += w.v * W_SPEED; - p.trail.push({ lon: p.lon, lat: p.lat }); + p.trail.push({ nx: p.lon / 360 + 0.5, ny: latToNormY(p.lat) }); if (p.trail.length > W_TRAIL) p.trail.shift(); if (p.trail.length > 1) { - windCtx.strokeStyle = windColors[Math.min(255, Math.floor(spd/25*255))]; - windCtx.beginPath(); - const p0 = map.latLngToContainerPoint([p.trail[0].lat, p.trail[0].lon]); - windCtx.moveTo(p0.x, p0.y); - for (let t = 1; t < p.trail.length; t++) { - const pt = map.latLngToContainerPoint([p.trail[t].lat, p.trail[t].lon]); - windCtx.lineTo(pt.x, pt.y); + colorBins[Math.min(255, Math.floor(spd/25*255))].push(p.trail); + } + } + + windCtx.lineWidth = 0.8; + for (let c = 0; c < 256; c++) { + const bin = colorBins[c]; + if (bin.length === 0) continue; + windCtx.strokeStyle = windColors[c]; + windCtx.beginPath(); + for (let j = 0; j < bin.length; j++) { + const trail = bin[j]; + windCtx.moveTo(trail[0].nx * scale + ox, trail[0].ny * scale + oy); + for (let t = 1; t < trail.length; t++) { + windCtx.lineTo(trail[t].nx * scale + ox, trail[t].ny * scale + oy); } - windCtx.stroke(); } + windCtx.stroke(); } windAnimFrame = requestAnimationFrame(animateWind); } - map.on('movestart zoomstart', () => { - windPaused = true; - if (windAnimFrame) { cancelAnimationFrame(windAnimFrame); windAnimFrame = null; } - }); - map.on('moveend zoomend', () => { - windPaused = false; - if (windGrid && !windAnimFrame) animateWind(); - }); - loadWindGrid(); diff --git a/public/index.html b/public/index.html index 6418bac..203d0af 100644 --- a/public/index.html +++ b/public/index.html @@ -1352,21 +1352,23 @@ let windEnabled = false; const PARTICLE_COUNT = 15000; const TRAIL_LENGTH = 25; - // At 60fps, 10 m/s wind should move ~10m per frame in real space. - // 10m = 0.00009° of latitude. So scale = 0.00009 / 10 = 0.000009 degrees per m/s per frame. - // But that would be invisible. Weather viz exaggerates by ~500x for visual flow. - // 0.000009 * 500 = 0.0045 degrees per m/s per frame const SPEED_SCALE = 0.004; + const W_DEG2RAD = Math.PI / 180; + const W_INV_2PI = 1 / (2 * Math.PI); + + // Normalized Mercator Y (matches Leaflet's EPSG:3857 SphericalMercator) + function latToNormY(lat) { + return 0.5 - Math.log(Math.tan(Math.PI / 4 + lat * W_DEG2RAD / 2)) * W_INV_2PI; + } // Wind speed color LUT — subtle white-to-light-blue palette const windColors = []; for (let i = 0; i < 256; i++) { const t = i / 255; - // Calm: dim white/gray → Strong: brighter white with blue tint const r = Math.round(140 + t * 115); const g = Math.round(150 + t * 100); const b = Math.round(180 + t * 75); - const a = 0.15 + t * 0.35; // 0.15 opacity for calm, 0.5 for strong + const a = 0.15 + t * 0.35; windColors.push(`rgba(${r},${g},${b},${a.toFixed(2)})`); } @@ -1375,7 +1377,6 @@ const resp = await fetch('/wind/grid'); if (!resp.ok) return; const data = await resp.json(); - // Decode base64 float arrays const uBuf = Uint8Array.from(atob(data.u), c => c.charCodeAt(0)); const vBuf = Uint8Array.from(atob(data.v), c => c.charCodeAt(0)); windGrid = { @@ -1409,59 +1410,76 @@ }; } + // Reusable wind sample output (avoids 15K object allocations per frame) + const _wind = { u: 0, v: 0 }; function sampleWind(lon, lat) { - if (!windGrid) return { u: 0, v: 0 }; - // Convert lon to 0-360 range + _wind.u = 0; _wind.v = 0; + if (!windGrid) return _wind; let glon = lon % 360; if (glon < 0) glon += 360; - // Grid indices const xi = glon / windGrid.dx; const yi = (windGrid.latMax - lat) / windGrid.dy; const x0 = Math.floor(xi), y0 = Math.floor(yi); const x1 = (x0 + 1) % windGrid.width, y1 = Math.min(y0 + 1, windGrid.height - 1); const fx = xi - x0, fy = yi - y0; - // Bilinear interpolation const idx00 = y0 * windGrid.width + x0; const idx10 = y0 * windGrid.width + x1; const idx01 = y1 * windGrid.width + x0; const idx11 = y1 * windGrid.width + x1; - const u = windGrid.u[idx00] * (1-fx)*(1-fy) + windGrid.u[idx10] * fx*(1-fy) + - windGrid.u[idx01] * (1-fx)*fy + windGrid.u[idx11] * fx*fy; - const v = windGrid.v[idx00] * (1-fx)*(1-fy) + windGrid.v[idx10] * fx*(1-fy) + - windGrid.v[idx01] * (1-fx)*fy + windGrid.v[idx11] * fx*fy; - return { u, v }; + const w00 = (1-fx)*(1-fy), w10 = fx*(1-fy), w01 = (1-fx)*fy, w11 = fx*fy; + _wind.u = windGrid.u[idx00]*w00 + windGrid.u[idx10]*w10 + windGrid.u[idx01]*w01 + windGrid.u[idx11]*w11; + _wind.v = windGrid.v[idx00]*w00 + windGrid.v[idx10]*w10 + windGrid.v[idx01]*w01 + windGrid.v[idx11]*w11; + return _wind; } + // Pre-allocate color bins for batched drawing (avoids per-frame allocation) + const colorBins = new Array(256); + for (let c = 0; c < 256; c++) colorBins[c] = []; + function animateWind() { - if (!windEnabled || !windCtx || !windGrid || windPaused) return; + if (!windEnabled || !windCtx || !windGrid) return; - // Resize canvas if needed + // Resize canvas only when size actually changes const container = document.getElementById('map'); const rect = container.getBoundingClientRect(); - if (windCanvas.width !== rect.width * devicePixelRatio) { - windCanvas.width = rect.width * devicePixelRatio; - windCanvas.height = rect.height * devicePixelRatio; + const dpr = devicePixelRatio; + const cw = Math.round(rect.width * dpr); + const ch = Math.round(rect.height * dpr); + if (windCanvas.width !== cw || windCanvas.height !== ch) { + windCanvas.width = cw; + windCanvas.height = ch; windCanvas.style.width = rect.width + 'px'; windCanvas.style.height = rect.height + 'px'; } - windCtx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + windCtx.setTransform(dpr, 0, 0, dpr, 0, 0); windCtx.clearRect(0, 0, rect.width, rect.height); + // Extract map transform ONCE per frame: one Leaflet call for the anchor, + // then pure arithmetic for all 375K trail points. + const center = map.getCenter(); + const ref = map.latLngToContainerPoint(center); + const scale = 256 * Math.pow(2, map.getZoom()); + const cNx = center.lng / 360 + 0.5; + const cNy = latToNormY(center.lat); + // containerX = ref.x + (nx - cNx) * scale + // containerY = ref.y + (ny - cNy) * scale + const ox = ref.x - cNx * scale; + const oy = ref.y - cNy * scale; + const mapBounds = map.getBounds(); const west = mapBounds.getWest(), east = mapBounds.getEast(); const south = mapBounds.getSouth(), north = mapBounds.getNorth(); - windCtx.lineWidth = 0.8; - windCtx.globalAlpha = 1; + // Clear color bins + for (let c = 0; c < 256; c++) colorBins[c].length = 0; + // Update particles and bin trails by color for (let i = 0; i < windParticles.length; i++) { const p = windParticles[i]; p.age++; - // Respawn if too old or off-screen if (p.age > p.maxAge || p.lat < -85 || p.lat > 85) { - // Spawn within current viewport windParticles[i] = { lon: west + Math.random() * (east - west), lat: south + Math.random() * (north - south), @@ -1472,48 +1490,46 @@ continue; } - // Sample wind and move const wind = sampleWind(p.lon, p.lat); const speed = Math.sqrt(wind.u * wind.u + wind.v * wind.v); - // Move particle (scale by cos(lat) for longitude) - const cosLat = Math.cos(p.lat * Math.PI / 180); + const cosLat = Math.cos(p.lat * W_DEG2RAD); p.lon += (wind.u * SPEED_SCALE) / Math.max(cosLat, 0.1); p.lat += wind.v * SPEED_SCALE; - // Store trail in lat/lon (not screen coords — survives pan/zoom) - p.trail.push({ lon: p.lon, lat: p.lat, speed }); + // Store trail in normalized Mercator (trig computed once per particle, + // drawing uses only multiply+add per trail point) + const nx = p.lon / 360 + 0.5; + const ny = latToNormY(p.lat); + p.trail.push({ nx, ny }); if (p.trail.length > TRAIL_LENGTH) p.trail.shift(); - // Draw trail by converting each point to screen coords at draw time if (p.trail.length > 1) { const colorIdx = Math.min(255, Math.floor(speed / 25 * 255)); - windCtx.strokeStyle = windColors[colorIdx]; - windCtx.beginPath(); - const p0 = map.latLngToContainerPoint([p.trail[0].lat, p.trail[0].lon]); - windCtx.moveTo(p0.x, p0.y); - for (let t = 1; t < p.trail.length; t++) { - const pt = map.latLngToContainerPoint([p.trail[t].lat, p.trail[t].lon]); - windCtx.lineTo(pt.x, pt.y); + colorBins[colorIdx].push(p.trail); + } + } + + // Draw all trails batched by color — reduces stroke() calls from 15K to ~20 + windCtx.lineWidth = 0.8; + for (let c = 0; c < 256; c++) { + const bin = colorBins[c]; + if (bin.length === 0) continue; + windCtx.strokeStyle = windColors[c]; + windCtx.beginPath(); + for (let j = 0; j < bin.length; j++) { + const trail = bin[j]; + windCtx.moveTo(trail[0].nx * scale + ox, trail[0].ny * scale + oy); + for (let t = 1; t < trail.length; t++) { + windCtx.lineTo(trail[t].nx * scale + ox, trail[t].ny * scale + oy); } - windCtx.stroke(); } + windCtx.stroke(); } windAnimFrame = requestAnimationFrame(animateWind); } - // Pause animation during map interaction to avoid jank - let windPaused = false; - map.on('movestart zoomstart', () => { - windPaused = true; - if (windAnimFrame) { cancelAnimationFrame(windAnimFrame); windAnimFrame = null; } - }); - map.on('moveend zoomend', () => { - windPaused = false; - if (windEnabled && !windAnimFrame) animateWind(); - }); - document.getElementById('show-wind')?.addEventListener('change', function() { windEnabled = this.checked; windCanvas.style.display = windEnabled ? 'block' : 'none';