PageSourceSearch

https://meteokav.gr/wxmap/assets/js/radar.js?v=1780848675

js meteokav.gr collected 2026-10-02 06:19:27 UTC 15,875 bytes, 339 lines download raw bytes

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.