1/** 2 * Meteokav WX Map â radar rain overlay (RainViewer) 3 * ------------------------------------------------- 4 * Live precipitation radar + ~2 h history, from RainViewer's FREE public API 5 * (no key, CORS-open). Frames come as a discrete list from weather-maps.json; 6 * each is a standard XYZ PNG tile set. 7 * 8 * SINGLE-LAYER design (deliberate): one raster layer held at a CONSTANT opacity, 9 * and we just swap the source's tile URLs per frame. Because there is never any 10 * opacity change, the overlay can't "pulse"/breathe. MapLibre keys raster tiles by 11 * z/x/y, so on a frame swap it keeps showing the old tile until the new one loads â 12 * and since we pre-load the whole sequence into the browser cache up front, that 13 * swap is effectively instant (no blank flash). This mirrors how Leaflet-based 14 * radar loops (incl. RainViewer's own) animate. 15 * 16 * Mutually exclusive with the satellite layer (coordinated in app.js). 17 */ 18window.WXRADAR = (function () { 19 'use strict'; 20 21 var SRC = 'wx-radar', LAYER = 'wx-radar-layer'; 22 var API = 'https://api.rainviewer.com/public/weather-maps.json'; 23 24 // Tile template params: /{size}/{z}/{x}/{y}/{color}/{smooth}_{snow}.png 25 // RainViewer's radar tiles only carry real data up to z7 (z8+ returns a 26 // "Zoom Level Not Supported" placeholder), so MAXZOOM=7 â MapLibre overzooms the 27 // z7 tiles for closer views. TILE_PX 512 super-sampled into 256-px tiles keeps 28 // that overzoom crisp. 29 var TILE_PX = 512, TILE_SIZE = 256, COLOR = 2 /* Universal Blue */, SMOOTH_SNOW = '1_1'; 30 var OPACITY = 0.8, MAXZOOM = 7; 31 var REFRESH_MS = 3 * 60 * 1000; // re-poll the frame list every ~3 min 32 33 var map = null; 34 var on = false; 35 var host = ''; 36 var pastFrames = []; // [{ time:<unix s>, path:'/v2/radar/...' }], oldest â newest 37 var curKey = null; // frame path currently shown 38 var poll = null; 39 var warm = {}; // url â Image (kept alive so the browser caches the tile) 40 41 function tmpl(key) { return host + key + '/' + TILE_PX + '/{z}/{x}/{y}/' + COLOR + '/' + SMOOTH_SNOW + '.png'; } 42 function tileUrl(key, z, x, y) { return host + key + '/' + TILE_PX + '/' + z + '/' + x + '/' + y + '/' + COLOR + '/' + SMOOTH_SNOW + '.png'; } 43 function latestKey() { return pastFrames.length ? pastFrames[pastFrames.length - 1].path : null; } 44 45 function firstLabelLayer() { 46 if (!map || !map.getStyle) return undefined; 47 var layers = (map.getStyle().layers) || []; 48 for (var i = 0; i < layers.length; i++) { if (layers[i].type === 'symbol') return layers[i].id; } 49 return undefined; 50 } 51 52 function removeLayer() { 53 if (!map) return; 54 if (map.getLayer(LAYER)) map.removeLayer(LAYER); 55 if (map.getSource(SRC)) map.removeSource(SRC); 56 } 57 58 function addLayer() { 59 if (!map || !curKey) return; 60 // If toggled while the style is mid-update (common on mobile, right after the 61 // exclusivity swap), defer rather than silently bail â so the layer reliably 62 // appears on the FIRST toggle without needing a retry. 63 if (!map.isStyleLoaded()) { map.once('idle', function () { if (on) addLayer(); }); return; } 64 removeLayer(); 65 map.addSource(SRC, { 66 type: 'raster', tiles: [tmpl(curKey)], tileSize: TILE_SIZE, maxzoom: MAXZOOM, 67 attribution: 'Radar: © <a href="https://www.rainviewer.com/" target="_blank" rel="noopener">RainViewer</a>' 68 }); 69 map.addLayer({ 70 id: LAYER, type: 'raster', source: SRC, 71 // Constant opacity, no tile fade â the frame swap is just a tile-content change. 72 paint: { 'raster-opacity': OPACITY, 'raster-fade-duration': 0, 'raster-resampling': 'linear' } 73 }, firstLabelLayer()); 74 } 75 76 // Swap to a frame: just point the source at the new frame's tiles. Opacity never 77 // changes, so there is no pulse; old tiles stay until the (pre-cached) new ones load. 78 function setFrame(key) { 79 if (!map || !host || !key || key === curKey) return; 80 curKey = key; 81 var s = map.getSource(SRC); 82 if (s && s.setTiles) s.setTiles([tmpl(key)]); 83 } 84 85 // Visible XYZ tiles (+1 ring) for the current view at the source's capped zoom. 86 function viewportTiles() { 87 var z = Math.max(0, Math.min(MAXZOOM, Math.round(map.getZoom()))); 88 var n = Math.pow(2, z), b = map.getBounds(); 89 function xt(lng) { return Math.floor(((lng + 180) / 360) * n); } 90 function yt(lat) { var r = lat * Math.PI / 180; return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * n); } 91 var x0 = xt(b.getWest()), x1 = xt(b.getEast()), y0 = yt(b.getNorth()), y1 = yt(b.getSouth()); 92 var out = []; 93 for (var x = x0 - 1; x <= x1 + 1; x++) { 94 for (var y = y0 - 1; y <= y1 + 1; y++) { 95 if (x >= 0 && y >= 0 && x < n && y < n) out.push([z, x, y]); 96 } 97 } 98 return out; 99 } 100 101 // Warm the browser cache with EVERY frame's tiles for the current view, so frame 102 // swaps during playback are instant. THROTTLED (few at a time) so the burst can't 103 // starve the VISIBLE layer's own tiles â that starvation was why the layer often 104 // didn't load on the first mobile toggle. 105 var pQueue = [], pActive = 0, PRELOAD_CONCURRENCY = 3; 106 function preloadAll() { 107 if (!map || !host || !pastFrames.length) return; 108 var tiles = viewportTiles();
109 pastFrames.forEach(function (f) { 110 tiles.forEach(function (t) { 111 var u = tileUrl(f.path, t[0], t[1], t[2]); 112 if (!warm[u]) { warm[u] = true; pQueue.push(u); } 113 }); 114 }); 115 pumpPreload(); 116 } 117 function pumpPreload() { 118 while (on && pActive < PRELOAD_CONCURRENCY && pQueue.length) { 119 pActive++; 120 var im = new Image(); 121 im.onload = im.onerror = function () { pActive--; pumpPreload(); }; 122 im.src = pQueue.shift(); 123 } 124 } 125 var preloadTimer = null; 126 function onMove() { // debounce re-preload after pan/zoom 127 if (!on) return; 128 clearTimeout(preloadTimer); 129 preloadTimer = setTimeout(preloadAll, 250); 130 } 131 132 // Validate the feed: keep only well-formed, strictly chronological, non-future 133 // frames â so a bad/partial RainViewer update can't inject a malformed, duplicate 134 // or out-of-order frame (logged so you can spot when it happens). 135 function sanitize(frames) { 136 var now = Date.now() / 1000 + 90, out = [], lastT = -Infinity, dropped = 0; 137 for (var i = 0; i < (frames || []).length; i++) { 138 var f = frames[i]; 139 if (!f || !f.path || typeof f.time !== 'number' || f.time > now || f.time <= lastT) { dropped++; continue; } 140 out.push(f); lastT = f.time; 141 } 142 if (dropped && window.console) console.warn('[radar] skipped ' + dropped + ' malformed/out-of-order frame(s) from RainViewer'); 143 return out; 144 } 145 function fetchFrames(cb) { 146 fetch(API, { cache: 'no-store' }) 147 .then(function (r) { return r.json(); }) 148 .then(function (j) { 149 host = j.host || ''; 150 pastFrames = sanitize(j.radar && j.radar.past); 151 if (cb) cb(pastFrames.length > 0); 152 }) 153 .catch(function () { if (cb) cb(false); }); 154 } 155 156 // Re-fetch the API and reflect any new frame on the map (advances LIVE, slides the 157 // window). Called on a timer AND immediately whenever the tab regains focus. 158 function pollNow() { 159 if (!on) return; 160 fetchFrames(function (ok) { 161 if (ok && on) { preloadAll(); if (window.WXPLAYER && WXPLAYER.isOn()) WXPLAYER.refresh(); } 162 }); 163 } 164 function startPoll() { stopPoll(); poll = setInterval(pollNow, REFRESH_MS); } 165 function stopPoll() { if (poll) { clearInterval(poll); poll = null; } } 166 167 /* ---- public API ---- */ 168 // setMode('rain', cb) â fetch + build (cb(true) on success); setMode(null) â off. 169 function setMode(mode, cb) { 170 if (mode === 'rain') { 171 fetchFrames(function (ok) { 172 if (!ok) { on = false; document.body.classList.remove('wx-radar-on'); if (cb) cb(false); return; } 173 on = true; 174 curKey = latestKey(); 175 addLayer(); 176 // Defer warming the rest of the sequence so the VISIBLE frame loads first. 177 setTimeout(function () { if (on) preloadAll(); }, 900); 178 map.on('moveend', onMove); 179 document.body.classList.add('wx-radar-on'); 180 startPoll(); 181 if (cb) cb(true); 182 }); 183 } else { 184 on = false; 185 if (map) map.off('moveend', onMove); 186 removeLayer(); 187 curKey = null; 188 warm = {}; 189 pQueue = []; // stop any pending preloads (in-flight ones drain out) 190 probeCache = {}; 191 hideTip(); 192 document.body.classList.remove('wx-radar-on'); 193 stopPoll(); 194 if (cb) cb(false); 195 } 196 } 197 function isOn() { return on; } 198 199 // Driver for the shared WXPLAYER. Frames = the API past list; last = LIVE. 200 // Single layer â no dissolve (fadeMs unused); the player just calls setFrame. 201 function driver() { 202 return { 203 timing: { frameMs: 480, fadeMs: 0, holdMs: 900 }, // crisp steps + pause on LIVE 204 frames: function () { 205 var n = pastFrames.length; 206 return pastFrames.map(function (f, i) { 207 return { t: f.time * 1000, live: i === n - 1, key: f.path }; 208 }); 209 }, 210 render: function (f) { setFrame(f.key); }, 211 liveLabel: function () { 212 var d = window.WXI18N && WXI18N[(window.WX && WX.getLang && WX.getLang()) || 'el']; 213 return (d && d.satLive) || 'LIVE'; 214 }, 215 onHide: function () {} 216 }; 217 } 218 219 // Re-add after a style swap (theme/language rebuilds the style â drops our layer). 220 function syncLayer() { 221 if (on && map && map.isStyleLoaded() && !map.getSource(SRC) && curKey) addLayer(); 222 } 223 224 /* ---------------------------------------------------------------------------- 225 * dBZ probe â hover (desktop) / tap (touch) reads the radar value at a point. 226 * RainViewer tiles are colour-coded (no value API), so we fetch the discrete 227 * `smooth=0` tile under the cursor, read the pixel, and map its colour to the 228 * nearest dBZ band (palette sampled from real scheme-2 tiles). Rain rate via 229 * Marshall-Palmer (Z = 200·R^1.6). Shown only over actual precipitation. 230 * -------------------------------------------------------------------------- */ 231 var PROBE_Z = 7; // radar data zoom (capped); we always query z7 232 var PROBE_PX = 512; // read the 512-px tile â 2à location precision 233 // Full RainViewer scheme-2 (smooth=0) ramp â dBZ, sampled from real tiles. np = 234 // no meaningful precipitation (olive/tan). Covers the heavy deep-red and the 235 // extreme magenta/pink bands that were previously unrecognised (read as "clear"). 236 var DBZ_LUT = [ 237 { d: 6, np: true, c: [128, 120, 101] }, { d: 8, np: true, c: [180, 168, 125] }, 238 { d: 8, np: true, c: [212, 198, 142] }, 239 { d: 10, c: [136, 221, 238] }, { d: 13, c: [81, 197, 232] }, { d: 16, c: [0, 163, 224] }, 240 { d: 20, c: [0, 119, 170] }, { d: 25, c: [0, 85, 136] }, { d: 30, c: [255, 238, 0] }, 241 { d: 35, c: [255, 170, 0] }, { d: 40, c: [255, 68, 0] }, { d: 45, c: [193, 0, 0] }, 242 { d: 48, c: [168, 0, 0] }, { d: 50, c: [143, 0, 0] }, { d: 53, c: [118, 0, 0] }, 243 { d: 55, c: [93, 0, 0] }, { d: 58, c: [255, 160, 255] }, { d: 61, c: [255, 120, 255] }, 244 { d: 64, c: [255, 85, 255] } 245 ]; 246 var tip = null, probeCache = {}, tapTO = null; 247 248 function nearest(r, g, b) { 249 var best = null, bd = 1e9; 250 for (var i = 0; i < DBZ_LUT.length; i++) {
251 var e = DBZ_LUT[i], dr = r - e.c[0], dg = g - e.c[1], db = b - e.c[2], dist = dr * dr + dg * dg + db * db; 252 if (dist < bd) { bd = dist; best = e; } 253 } 254 return { e: best, dist: Math.sqrt(bd) }; 255 } 256 function rateFromDbz(d) { return Math.pow(Math.pow(10, d / 10) / 200, 1 / 1.6); } 257 258 function tileFor(lng, lat) { 259 var n = Math.pow(2, PROBE_Z), fx = (lng + 180) / 360 * n; 260 var r = lat * Math.PI / 180, fy = (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * n; 261 var x = Math.floor(fx), y = Math.floor(fy); 262 return { x: x, y: y, px: Math.floor((fx - x) * PROBE_PX), py: Math.floor((fy - y) * PROBE_PX) }; 263 } 264 function sample(rec, px, py) { 265 if (!rec.ok) return null; 266 var i = (py * PROBE_PX + px) * 4, d = rec.data; 267 if (d[i + 3] < 30) return null; // transparent â no data 268 var nm = nearest(d[i], d[i + 1], d[i + 2]); 269 if (nm.dist > 110 || nm.e.np) return null; // unrecognised / no precip â nothing 270 return { dbz: nm.e.d, rate: rateFromDbz(nm.e.d) }; 271 } 272 function readAt(lng, lat, cb) { 273 if (!on || !host || !curKey) return cb(null); 274 var t = tileFor(lng, lat), key = curKey + '/' + t.x + '/' + t.y, rec = probeCache[key]; 275 if (rec && rec.ready) return cb(sample(rec, t.px, t.py)); 276 if (rec) { rec.waiters.push(function () { cb(sample(rec, t.px, t.py)); }); return; } 277 rec = { ready: false, ok: false, waiters: [] }; probeCache[key] = rec; 278 var im = new Image(); im.crossOrigin = 'anonymous'; 279 im.onload = function () { 280 var cv = document.createElement('canvas'); cv.width = PROBE_PX; cv.height = PROBE_PX; 281 var ctx = cv.getContext('2d'); ctx.drawImage(im, 0, 0); 282 try { rec.data = ctx.getImageData(0, 0, PROBE_PX, PROBE_PX).data; rec.ok = true; } catch (e) { rec.ok = false; } 283 rec.ready = true; cb(sample(rec, t.px, t.py)); rec.waiters.forEach(function (f) { f(); }); 284 }; 285 im.onerror = function () { rec.ready = true; cb(null); rec.waiters.forEach(function (f) { f(); }); }; 286 im.src = host + curKey + '/' + PROBE_PX + '/' + PROBE_Z + '/' + t.x + '/' + t.y + '/' + COLOR + '/0_0.png'; 287 } 288 289 function dict() { return (window.WXI18N && WXI18N[(window.WX && WX.getLang && WX.getLang()) || 'el']) || {}; } 290 function fmtRate(r) { 291 if (r >= 100) return '>100'; // beyond this it's hail, not rain 292 return r < 0.1 ? '<0.1' : (r < 1 ? r.toFixed(1) : String(Math.round(r))); 293 } 294 function ensureTip() { 295 if (tip) return; 296 tip = document.createElement('div'); tip.className = 'wx-radar-tip glass'; 297 tip.style.display = 'none'; document.body.appendChild(tip); 298 } 299 function hideTip() { if (tip) tip.style.display = 'none'; } 300 function showTip(point, v) { 301 if (!v) { hideTip(); return; } 302 ensureTip(); 303 var D = dict(), cat = v.dbz >= 50 ? D.rVeryHeavy : v.dbz >= 40 ? D.rHeavy 304 : v.dbz >= 30 ? D.rModerate : v.dbz >= 20 ? D.rLight : D.rDrizzle; 305 tip.innerHTML = '<span class="rt-cat">' + (cat || '') + '</span>' + 306 '<span class="rt-v">' + v.dbz + ' dBZ · ' + fmtRate(v.rate) + ' mm/h</span>'; 307 tip.style.display = 'flex'; 308 var w = tip.offsetWidth, h = tip.offsetHeight, c = map.getContainer().getBoundingClientRect(); 309 var x = point.x + 16, y = point.y + 16; 310 if (x + w > c.width) x = point.x - w - 16; 311 if (y + h > c.height) y = point.y - h - 16; 312 tip.style.left = (c.left + x) + 'px'; tip.style.top = (c.top + y) + 'px'; 313 } 314 function onProbeMove(e) { if (!on) { hideTip(); return; } readAt(e.lngLat.lng, e.lngLat.lat, function (v) { showTip(e.point, v); }); } 315 function onTap(e) { 316 if (!on) return; 317 readAt(e.lngLat.lng, e.lngLat.lat, function (v) { 318 showTip(e.point, v); 319 clearTimeout(tapTO); if (v) tapTO = setTimeout(hideTip, 3500); 320 }); 321 } 322 323 function init(m) { 324 map = m; 325 map.on('styledata', syncLayer); 326 map.on('idle', syncLayer); 327 // Background tabs throttle/pause timers, so when the client returns to the tab, 328 // immediately re-poll â the latest RainViewer frame shows without a page reload. 329 document.addEventListener('visibilitychange', function () { if (!document.hidden) pollNow(); }); 330 // Hover readout on desktop; tap readout on touch. 331 if (window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) { 332 map.on('mousemove', onProbeMove); map.on('mouseout', hideTip); 333 } else { 334 map.on('click', onTap); 335 } 336 } 337 338 return { init: init, setMode: setMode, isOn: isOn, setFrame: setFrame, driver: driver }; 339})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.