diff --git a/app/public/app-ui.css b/app/public/app-ui.css index 48f70b8..ef287d3 100644 --- a/app/public/app-ui.css +++ b/app/public/app-ui.css @@ -342,3 +342,9 @@ body.map-fs #tab-unified-map { padding: 0; } .sticky-head { background: color-mix(in srgb, var(--paper) 96%, transparent) !important; } .hero-hilo { background: rgba(0, 0, 0, 0.24) !important; } .topbar, .bottom-nav { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } + +/* readout chips follow their layer toggle (author display rules would otherwise beat [hidden]) */ +#tab-unified-map .map-overlay-readouts[hidden], #tab-unified-map .map-readout-chip[hidden] { display: none !important; } + +/* Location dot: the one thing on the map by default, so make it unmistakable (static halo, no animation) */ +.map-location-dot { width: 20px; height: 20px; background: #2563eb; border: 3px solid #fff; border-radius: 50%; box-shadow: 0 0 0 7px rgba(37, 99, 235, 0.28), 0 2px 8px rgba(0, 0, 0, 0.55); } diff --git a/app/public/app.js b/app/public/app.js index ba947f7..e0d42ee 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -176,6 +176,8 @@ async function flyToHurricane(storm) { if (sat && !sat.checked) { sat.checked = true; sat.dispatchEvent(new Event('change')); + // a storm tap is a one-off look, not a change to the user's saved map layers + try { localStorage.removeItem(STORAGE_KEYS.showSatelliteLayer); } catch {} } await new Promise(r => setTimeout(r, 450)); @@ -358,6 +360,8 @@ async function renderCloudOverlay(map) { } }); if (on) map.__cloudStamp = stamp; + // Radar frames must always draw above the clouds (they may have been added after this layer), or rain disappears + map.getStyle().layers.filter(l => l.id.startsWith('rv-radar-')).forEach(l => map.moveLayer(l.id)); } catch { /* style swapped mid-update; next refresh retries */ } } @@ -490,7 +494,7 @@ const STORAGE_KEYS = { showLightning: 'weather.showLightning', showWind: 'weather.showWind', showRadarLayer: 'weather.map.radar', - showSatelliteLayer: 'weather.map.satellite', + showSatelliteLayer: 'weather.map.goesIR', // new key: the old 'satellite' toggle never drew anything, ignore its stale value showLightningLayer: 'weather.map.lightning', showWindLayer: 'weather.map.wind', showAqiLayer: 'weather.map.aqi', @@ -537,12 +541,12 @@ const state = { unifiedMapLayers: { radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false', satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) === 'true', - lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) !== 'false', - wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) !== 'false', - aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) !== 'false', - alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) !== 'false', - temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) !== 'false', - sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) !== 'false', + lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) === 'true', + wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) === 'true', + aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) === 'true', + alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) === 'true', + temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) === 'true', + sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) === 'true', hurricane: localStorage.getItem(STORAGE_KEYS.showHurricaneLayer) === 'true', wildfire: localStorage.getItem(STORAGE_KEYS.showWildfireLayer) === 'true', spc: localStorage.getItem(STORAGE_KEYS.showSpcLayer) === 'true', @@ -1048,7 +1052,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { function addFrameLayer(frame, forMode) { const id = `rv-${forMode}-${frame.time}`; if (!map.getSource(id)) { - map.addSource(id, { type: 'raster', tiles: [frameTileUrl(frame, forMode)], tileSize: 256 }); + map.addSource(id, { type: 'raster', tiles: [frameTileUrl(frame, forMode)], tileSize: 256, ...(frame.maxNativeZoom ? { maxzoom: frame.maxNativeZoom } : {}) }); map.addLayer({ id, type: 'raster', source: id, paint: { @@ -2611,11 +2615,25 @@ document.querySelectorAll('#tab-unified-map input[type="checkbox"][data-layer]') } if (layerName === 'satellite') renderCloudOverlay(mapsOverview.map); + syncReadoutChips(); if (state.currentCity) refreshMapsOverview(); }); }); + +// The top-left readout chips only show for layers the user switched on +function syncReadoutChips() { + const L = state.unifiedMapLayers; + [['overlay-temp', L.temp], ['overlay-aqi', L.aqi], ['overlay-sunmoon', L.sunMoon]].forEach(([id, on]) => { + const el = document.getElementById(id); + if (el) el.hidden = !on; + }); + const box = document.getElementById('map-overlay-readouts'); + if (box) box.hidden = !(L.temp || L.aqi || L.sunMoon); +} +syncReadoutChips(); + const baseMapSelect = document.getElementById('base-map-select'); if (baseMapSelect) { baseMapSelect.value = state.unifiedMapBaseLayer; diff --git a/app/server.js b/app/server.js index 77ff64f..f3f1617 100644 --- a/app/server.js +++ b/app/server.js @@ -909,12 +909,38 @@ app.get('/api/alerts', async (req, res) => { } }); -// Radar/satellite tile configuration +// Radar tile configuration. +// Primary: RainViewer (global composite, 10-min frames, free, tiles load straight from their CDN). +// Fallback: IEM NEXRAD (US only). IEM blocks IPs that pull too many tiles and answers with a "sorry" +// redirect, so it must not be the only source. Satellite imagery is separate (NASA GIBS, client-side). +let rainviewerCache = { at: 0, data: null }; +async function rainviewerFrames() { + if (rainviewerCache.data && Date.now() - rainviewerCache.at < 120000) return rainviewerCache.data; + const r = await fetchWithRetry('https://api.rainviewer.com/public/weather-maps.json', { attempts: 2, timeoutMs: 8000 }); + if (!r.ok) throw new Error(`RainViewer ${r.status}`); + const j = await r.json(); + const host = j.host || 'https://tilecache.rainviewer.com'; + const frames = (j.radar?.past || []).slice(-12).map(f => ({ + time: f.time, + url: `${host}${f.path}/256/{z}/{x}/{y}/2/1_1.png`, // colour scheme 2, smoothed, snow shown + isForecast: false, + maxNativeZoom: 7, // RainViewer's free tiles stop at z7; MapLibre overzooms beyond that + })); + if (!frames.length) throw new Error('RainViewer returned no frames'); + rainviewerCache = { at: Date.now(), data: frames }; + return frames; +} + app.get('/api/radar/times', async (req, res) => { try { - const now = Math.floor(Date.now() / 1000); + try { + const past = await rainviewerFrames(); + return res.json({ provider: 'rainviewer', radar: { past }, satellite: { infrared: [] } }); + } catch (e) { + console.error('RainViewer unavailable, falling back to IEM:', e.message); + } - // IEM NEXRAD tile service (US radar, public/free) with historical offsets for animation. + const now = Math.floor(Date.now() / 1000); const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0'; const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0]; const radarPast = radarOffsetsMin.map(offset => { @@ -926,28 +952,7 @@ app.get('/api/radar/times', async (req, res) => { maxNativeZoom: 12, }; }); - - // IEM GOES East cloud/satellite frames for animation. - const satOffsetsMin = [30, 25, 20, 15, 10, 5, 0]; - const satelliteInfrared = satOffsetsMin.map(offset => { - const layer = offset === 0 ? 'goes_east' : `goes_east_m${String(offset).padStart(2, '0')}m`; - return { - time: now - (offset * 60), - url: `https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/${layer}/{z}/{x}/{y}.png`, - isForecast: false, - maxNativeZoom: 10, - }; - }); - - res.json({ - provider: 'iem-radar-sat', - radar: { - past: radarPast, - }, - satellite: { - infrared: satelliteInfrared, - }, - }); + res.json({ provider: 'iem-radar', radar: { past: radarPast }, satellite: { infrared: [] } }); } catch (e) { console.error('Radar config error:', e.message); res.status(500).json({ error: 'Radar times failed' });