PageSourceSearch

https://www.tricityrecordnm.com/wp-content/themes/ballantine-news-theme/js/river-flows.js?ver=1.1

js tricityrecordnm.com collected 2026-09-29 06:15:38 UTC 23,003 bytes, 533 lines download raw bytes

1/**
2 * river-flows.js
3 *
4 * Fetches USGS streamflow data entirely client-side after page load so the
5 * server renders instantly — zero blocking HTTP calls on the PHP side.
6 * Results are cached in sessionStorage for 1 hour per river.
7 *
8 * USGS APIs used (no API key required):
9 *   IV  https://waterservices.usgs.gov/nwis/iv/  — current instantaneous reading
10 *   DV  https://waterservices.usgs.gov/nwis/dv/  — daily mean values per year
11 */
12(function () {
13    'use strict';
14
15    /* ── Config ──────────────────────────────────────────────────────────── */
16    var YEAR_COLORS  = { 0: '#900', 1: '#aaa', 2: '#bbb', 3: '#ccc' };
17    var CURRENT_YEAR = new Date().getFullYear();
18    var PAD          = { top: 30, right: 24, bottom: 54, left: 64 };
19    var CACHE_TTL    = 3600 * 1000; // 1 hour in ms
20
21    /* ── sessionStorage cache ────────────────────────────────────────────── */
22    function cacheGet(siteNo) {
23        try {
24            var raw = sessionStorage.getItem('hr_' + siteNo);
25            if (!raw) return null;
26            var obj = JSON.parse(raw);
27            if (Date.now() - (obj._ts || 0) > CACHE_TTL) return null;
28            return obj.data;
29        } catch (e) { return null; }
30    }
31    function cacheSet(siteNo, data) {
32        try {
33            sessionStorage.setItem('hr_' + siteNo, JSON.stringify({ _ts: Date.now(), data: data }));
34        } catch (e) {}
35    }
36
37    /* ── USGS fetch helpers ──────────────────────────────────────────────── */
38    function getJSON(url) {
39        return fetch(url).then(function (r) {
40            if (!r.ok) return Promise.reject(r.status);
41            return r.json();
42        });
43    }
44
45    function fetchCurrent(siteNo) {
46        var url = 'https://waterservices.usgs.gov/nwis/iv/?format=json&sites=' + siteNo
47                + '&parameterCd=00060&siteStatus=active';
48        return getJSON(url).then(function (d) {
49            var s = d && d.value && d.value.timeSeries && d.value.timeSeries[0];
50            if (!s) return null;
51            var v = s.values && s.values[0] && s.values[0].value;
52            if (!v || !v.length) return null;
53            var last = v[v.length - 1];
54            var cfs  = parseFloat(last.value);
55            return cfs >= 0 ? { cfs: cfs, dateTime: last.dateTime || '' } : null;
56        }).catch(function () { return null; });
57    }
58
59    function fetchYearDV(siteNo, yr) {
60        var today = new Date().toISOString().slice(0, 10);
61        var url   = 'https://waterservices.usgs.gov/nwis/dv/?format=json&sites=' + siteNo
62                  + '&parameterCd=00060&startDT=' + yr + '-01-01'
63                  + '&endDT=' + (yr === CURRENT_YEAR ? today : yr + '-12-31')
64                  + '&statCd=00003';
65        return getJSON(url).then(function (d) {
66            var s = d && d.value && d.value.timeSeries && d.value.timeSeries[0];
67            if (!s) return [];
68            var v = s.values && s.values[0] && s.values[0].value;
69            if (!v) return [];
70            var pts = [];
71            v.forEach(function (item) {
72                var cfs = parseFloat(item.value);
73                if (cfs >= 0) pts.push({ date: item.dateTime.slice(0, 10), cfs: cfs });
74            });
75            return pts;
76        }).catch(function () { return []; });
77    }
78
79    function fetchRiverData(siteNo) {
80        var hit = cacheGet(siteNo);
81        if (hit) return Promise.resolve(hit);
82
83        var years = [];
84        for (var y = CURRENT_YEAR - 3; y <= CURRENT_YEAR; y++) years.push(y);
85
86        return Promise.all([
87            fetchCurrent(siteNo),
88            Promise.all(years.map(function (yr) {
89                return fetchYearDV(siteNo, yr).then(function (pts) {
90                    return { yr: yr, pts: pts };
91                });
92            }))
93        ]).then(function (res) {
94            var yd = {};
95            res[1].forEach(function (g) { if (g.pts.length) yd[g.yr] = g.pts; });
96            var data = { current: res[0], years: yd };
97            cacheSet(siteNo, data);
98            return data;
99        });
100    }
101
102    /* ── Date helpers ────────────────────────────────────────────────────── */
103    function parseDate(str) {
104        var p = str.split('-');
105        return new Date(+p[0], +p[1] - 1, +p[2], 12);
106    }
107
108    function formatReading(isoStr) {
109        var dt = new Date(isoStr);
110        if (isNaN(dt)) return isoStr;
111        var days  = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
112        var months= ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
113        var h = dt.getHours(), ampm = h >= 12 ? 'PM' : 'AM';
114        h = h % 12 || 12;
115        var m = ('0' + dt.getMinutes()).slice(-2);
116        return days[dt.getDay()] + ', ' + months[dt.getMonth()] + ' ' + dt.getDate()
117             + ' \u2014 ' + h + ':' + m + ' ' + ampm;
118    }
119
120    /* ── Build chart state ───────────────────────────────────────────────── */
121    function makeState(canvas, data) {
122        var ys = {}, years = [];
123        Object.keys(data.years).forEach(function (yr) {
124            var n = parseInt(yr, 10);
125            ys[n] = data.years[yr]
126                .map(function (p) { return { date: parseDate(p.date), cfs: p.cfs }; })
127                .sort(function (a, b) { return a.date - b.date; });
128            years.push(n);
129        });
130        years.sort(function (a, b) { return b - a; });
131
132        var en = {};
133        years.forEach(function (y) { en[y] = true; });
134
135        return {
136            canvas:    canvas,
137            ys:        ys,
138            years:     years,
139            en:        en,
140            current:   data.current || null,
141            zoom:      null,
142            drag:      null,
143            hoverX:    null,
144            plotRect:  null,
145            xScale:    null,
146        };
147    }
148
149    /* ── Draw ────────────────────────────────────────────────────────────── */
150    function draw(s) {
151        var canvas = s.canvas;
152        var dpr    = window.devicePixelRatio || 1;
153        var W      = canvas.clientWidth  || canvas.offsetWidth;
154        var H      = canvas.clientHeight || 300;
155
156        if (canvas.width  !== Math.round(W * dpr) ||
157            canvas.height !== Math.round(H * dpr)) {
158            canvas.width  = Math.round(W * dpr);
159            canvas.height = Math.round(H * dpr);
160        }
161
162        var ctx = canvas.getContext('2d');
163        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
164        ctx.clearRect(0, 0, W, H);
165
166        var plotW = W - PAD.left - PAD.right;
167        var plotH = H - PAD.top  - PAD.bottom;
168        if (plotW < 10 || plotH < 10) return;
169
170        /* collect all enabled points */
171        var allPts = [];
172        s.years.forEach(function (yr) {
173            if (s.en[yr]) allPts = allPts.concat(s.ys[yr] || []);
174        });
175        if (!allPts.length) return;
176
177        /* x-range: use current-year span (aligned) or zoom */
178        var curPts = s.ys[CURRENT_YEAR] || [];
179        var minTs, maxTs;
180        if (s.zoom) {
181            minTs = s.zoom[0]; maxTs = s.zoom[1];
182        } else if (curPts.length) {
183            minTs = curPts[0].date.getTime();
184            maxTs = curPts[curPts.length - 1].date.getTime();
185        } else {
186            minTs = allPts.reduce(function (a, b) { return a.date < b.date ? a : b; }).date.getTime();
187            maxTs = allPts.reduce(function (a, b) { return a.date > b.date ? a : b; }).date.getTime();
188        }
189        var tsSpan = Math.max(maxTs - minTs, 1);
190
191        /* align historical year onto current-year x-axis */
192        function alignTs(date, yr) {
193            if (yr === CURRENT_YEAR) return date.getTime();
194            var d2 = new Date(date); d2.setFullYear(CURRENT_YEAR); return d2.getTime();
195        }
196
197        /* y-range */
198        var minCfs = Infinity, maxCfs = -Infinity;
199        s.years.forEach(function (yr) {
200            if (!s.en[yr]) return;
201            (s.ys[yr] || []).forEach(function (p) {
202                var t = alignTs(p.date, yr);
203                if (t >= minTs && t <= maxTs) {
204                    if (p.cfs < minCfs) minCfs = p.cfs;
205                    if (p.cfs > maxCfs) maxCfs = p.cfs;
206                }
207            });
208        });
209        if (!isFinite(minCfs)) { minCfs = 0; maxCfs = 1000; }
210        minCfs = 0;
211        maxCfs = Math.ceil(maxCfs * 1.1 / 250) * 250 || 250;
212
213        function xPx(ts)  { return PAD.left + (ts  - minTs) / tsSpan  * plotW; }
214        function yPx(cfs) { return PAD.top  + plotH - (cfs - minCfs) / (maxCfs - minCfs) * plotH; }
215
216        s.plotRect = { x: PAD.left, y: PAD.top, w: plotW, h: plotH };
217        s.xScale   = { minTs: minTs, maxTs: maxTs, tsSpan: tsSpan };
218
219        /* grid / y-axis */
220        var yStep  = niceStep(maxCfs - minCfs, 5);
221        var yStart = Math.floor(minCfs / yStep) * yStep;
222        ctx.strokeStyle = '#e8e8e8'; ctx.lineWidth = 1;
223        ctx.fillStyle   = '#888';    ctx.font = '11px sans-serif'; ctx.textAlign = 'right';
224        for (var tick = yStart; tick <= maxCfs + yStep * 0.5; tick += yStep) {
225            if (tick < 0) continue;
226            var yy = yPx(tick);
227            ctx.beginPath(); ctx.moveTo(PAD.left, yy); ctx.lineTo(PAD.left + plotW, yy); ctx.stroke();
228            ctx.fillText(Math.round(tick).toLocaleString(), PAD.left - 6, yy + 4);
229        }
230
231        /* x-axis / month ticks */
232        var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
233        var yrRef  = new Date(minTs).getFullYear();
234        ctx.fillStyle = '#888'; ctx.textAlign = 'center';
235        for (var mo = 0; mo < 12; mo++) {
236            var moDate = new Date(yrRef, mo, 15);
237            var moTs   = moDate.getTime();
238            if (moTs < minTs || moTs > maxTs) continue;
239            var mx = xPx(moTs);
240            ctx.strokeStyle = '#e8e8e8'; ctx.lineWidth = 1;
241            ctx.beginPath(); ctx.moveTo(mx, PAD.top); ctx.lineTo(mx, PAD.top + plotH); ctx.stroke();
242            ctx.fillText(months[mo], mx, PAD.top + plotH + 18);
243        }
244
245        /* y-axis label */
246        ctx.save();
247        ctx.translate(14, PAD.top + plotH / 2);
248        ctx.rotate(-Math.PI / 2);
249        ctx.fillStyle = '#666'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
250        ctx.fillText('Discharge (ft\u00B3/sec)', 0, 0);
251        ctx.restore();
252
253        /* series lines — oldest first so current year draws on top */
254        var drawOrder = s.years.slice().sort(function (a, b) { return a - b; });
255        drawOrder.forEach(function (yr) {
256            if (!s.en[yr]) return;
257            var pts = s.ys[yr] || [];
258            if (pts.length < 2) return;
259            var isCur = yr === CURRENT_YEAR;
260            ctx.beginPath();
261            ctx.strokeStyle = YEAR_COLORS[CURRENT_YEAR - yr] || '#ddd';
262            ctx.lineWidth   = isCur ? 2 : 1.2;
263            ctx.globalAlpha = isCur ? 1 : 0.55;
264            var started = false;
265            pts.forEach(function (p) {
266                var t = alignTs(p.date, yr);
267                if (t < minTs || t > maxTs) return;
268                var x = xPx(t), y = yPx(p.cfs);
269                if (!started) { ctx.moveTo(x, y); started = true; }
270                else ctx.lineTo(x, y);
271            });
272            ctx.stroke();
273        });
274        ctx.globalAlpha = 1;
275
276        /* current-reading dot */
277        if (s.current && s.en[CURRENT_YEAR]) {
278            var cd = parseDate(s.current.dateTime.slice(0, 10));
279            var ct = alignTs(cd, CURRENT_YEAR);
280            if (ct >= minTs && ct <= maxTs) {
281                var dotX = xPx(ct), dotY = yPx(s.current.cfs);
282                ctx.beginPath(); ctx.arc(dotX, dotY, 6, 0, Math.PI * 2);
283                ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fill();
284                ctx.strokeStyle = '#900'; ctx.lineWidth = 2; ctx.stroke();
285            }
286        }
287
288        /* hover crosshair */
289        if (s.hoverX !== null && s.hoverX >= PAD.left && s.hoverX <= PAD.left + plotW) {
290            drawHover(ctx, s, W, plotW, plotH, xPx, yPx, minTs, tsSpan);
291        }
292
293        /* drag-zoom rectangle */
294        if (s.drag && s.drag.endX !== undefined) {
295            var rx0 = Math.min(s.drag.startX, s.drag.endX);
296            var rx1 = Math.max(s.drag.startX, s.drag.endX);
297            ctx.fillStyle = 'rgba(144,0,0,0.07)';
298            ctx.fillRect(rx0, PAD.top, rx1 - rx0, plotH);
299            ctx.strokeStyle = 'rgba(144,0,0,0.3)'; ctx.lineWidth = 1;
300            ctx.strokeRect(rx0, PAD.top, rx1 - rx0, plotH);
301        }
302    }
303
304    function drawHover(ctx, s, W, plotW, plotH, xPx, yPx, minTs, tsSpan) {
305        var hx  = s.hoverX;
306        var hTs = minTs + (hx - PAD.left) / plotW * tsSpan;
307
308        var pts = s.ys[CURRENT_YEAR] || [];
309        if (!pts.length) return;
310
311        var nearest = null, bestD = Infinity;
312        pts.forEach(function (p) {
313            var t = p.date.getTime(), d = Math.abs(t - hTs);
314            if (d < bestD) { bestD = d; nearest = p; }
315        });
316        if (!nearest) return;
317
318        var px  = xPx(nearest.date.getTime());
319        var py  = yPx(nearest.cfs);
320        var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
321        var days   = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
322        var label  = days[nearest.date.getDay()] + ', ' + months[nearest.date.getMonth()] + ' ' + nearest.date.getDate();
323        var cfsLbl = nearest.cfs.toLocaleString(undefined, { maximumFractionDigits: 0 }) + ' cfs';
324
325        ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
326        ctx.setLineDash([4, 4]);
327        ctx.beginPath(); ctx.moveTo(px, PAD.top); ctx.lineTo(px, PAD.top + plotH); ctx.stroke();
328        ctx.setLineDash([]);
329
330        ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2);
331        ctx.fillStyle = '#900'; ctx.fill();
332        ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
333
334        var tipW = 180, tipH = 56;
335        var tipX = (px + 12 + tipW > W - PAD.right) ? px - tipW - 12 : px + 12;
336        var tipY = PAD.top + 10;
337
338        ctx.fillStyle = '#f4f4f4'; ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
339        ctx.beginPath();
340        if (ctx.roundRect) ctx.roundRect(tipX, tipY, tipW, tipH, 4);
341        else ctx.rect(tipX, tipY, tipW, tipH);
342        ctx.fill(); ctx.stroke();
343
344        ctx.fillStyle = '#111'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center';
345        ctx.fillText(label, tipX + tipW / 2, tipY + 20);
346        ctx.fillStyle = '#900'; ctx.font = 'bold 16px sans-serif';
347        ctx.fillText(cfsLbl, tipX + tipW / 2, tipY + 44);
348    }
349
350    /* ── Nice y-axis step ────────────────────────────────────────────────── */
351    function niceStep(range, n) {
352        var rough = range / n;
353        var exp   = Math.floor(Math.log10(rough));
354        var f     = rough / Math.pow(10, exp);
355        var nice  = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10;
356        return nice * Math.pow(10, exp);
357    }
358
359    /* ── Legend ──────────────────────────────────────────────────────────── */
360    function buildLegend(panel, s) {
361        var old = panel.querySelector('.wx-river-legend');
362        if (old) old.remove();
363
364        var wrap = document.createElement('div');
365        wrap.className = 'wx-river-legend';
366
367        s.years.forEach(function (yr) {
368            var color = YEAR_COLORS[CURRENT_YEAR - yr] || '#ddd';
369            var isCur = yr === CURRENT_YEAR;
370
371            var lbl = document.createElement('label');
372            lbl.className = 'wx-river-legend-item';
373
374            var cb = document.createElement('input');
375            cb.type = 'checkbox'; cb.checked = true;
376            cb.addEventListener('change', function () {
377                s.en[yr] = cb.checked; draw(s);
378            });
379
380            var sw = document.createElement('span');
381            sw.className = 'wx-river-legend-swatch';
382            sw.style.cssText = 'background:' + color + ';opacity:' + (isCur ? 1 : 0.6);
383
384            var txt = document.createElement('span');
385            txt.textContent = yr;
386            txt.style.cssText = 'font-weight:' + (isCur ? 700 : 400) + ';color:' + (isCur ? '#111' : '#888');
387
388            lbl.appendChild(cb); lbl.appendChild(sw); lbl.appendChild(txt);
389            wrap.appendChild(lbl);
390        });
391
392        /* insert after the chart-wrap div */
393        var chartWrap = panel.querySelector('.wx-river-chart-wrap');
394        if (chartWrap) chartWrap.insertAdjacentElement('afterend', wrap);
395        else panel.appendChild(wrap);
396    }
397
398    /* ── Mouse events ────────────────────────────────────────────────────── */
399    function bindEvents(s) {
400        var canvas = s.canvas;
401        canvas.style.cursor = 'crosshair';
402
403        canvas.addEventListener('mousemove', function (e) {
404            var rect = canvas.getBoundingClientRect();
405            var x    = e.clientX - rect.left;
406            if (s.drag) { s.drag.endX = x; draw(s); return; }
407            var pr = s.plotRect;
408            s.hoverX = (pr && x >= pr.x && x <= pr.x + pr.w) ? x : null;
409            draw(s);
410        });
411
412        canvas.addEventListener('mouseleave', function () {
413            s.hoverX = null; s.drag = null; draw(s);
414        });
415
416        canvas.addEventListener('mousedown', function (e) {
417            var rect = canvas.getBoundingClientRect();
418            var x    = e.clientX - rect.left;
419            if (!s.plotRect || x < s.plotRect.x || x > s.plotRect.x + s.plotRect.w) return;
420            s.drag = { startX: x };
421        });
422
423        canvas.addEventListener('mouseup', function (e) {
424            if (!s.drag) return;
425            var rect = canvas.getBoundingClientRect();
426            var x    = e.clientX - rect.left;
427            var dx   = Math.abs(x - s.drag.startX);
428            var startX = s.drag.startX;
429            s.drag = null;
430            if (dx < 8) { draw(s); return; }
431            var pr = s.plotRect, xs = s.xScale;
432            if (!pr || !xs) return;
433            var t1 = xs.minTs + (Math.min(startX, x) - pr.x) / pr.w * xs.tsSpan;
434            var t2 = xs.minTs + (Math.max(startX, x) - pr.x) / pr.w * xs.tsSpan;
435            s.zoom = [t1, t2]; draw(s);
436        });
437
438        canvas.addEventListener('dblclick', function () {
439            s.zoom = null; draw(s);
440        });
441
442        var ro = new ResizeObserver(function () { draw(s); });
443        ro.observe(canvas);
444    }
445
446    /* ── Update the reading callout ──────────────────────────────────────── */
447    function updateReading(key, current) {
448        var el    = document.getElementById('wx-river-reading-' + key);
449        var dtEl  = document.getElementById('wx-river-dt-'      + key);
450        var cfsEl = document.getElementById('wx-river-cfs-'     + key);
451        if (!el) return;
452        if (current && current.cfs >= 0) {
453            if (dtEl)  dtEl.textContent  = formatReading(current.dateTime);
454            if (cfsEl) cfsEl.textContent = Math.round(current.cfs).toLocaleStr
454ing() + 'cfs';
455            el.style.display = '';
456        } else {
457            el.classList.add('wx-river-reading--unavail');
458            el.textContent  = 'Data temporarily unavailable';
459            el.style.display = '';
460        }
461    }
462
463    /* ── Tab switching ───────────────────────────────────────────────────── */
464    var loaded  = {};   // siteNo => true once fetch started
465    var states  = {};   // riverKey => chart state
466
467    function activateTab(btn, section) {
468        section.querySelectorAll('.wx-river-tab').forEach(function (t) {
469            t.classList.remove('wx-river-tab--active');
470            t.setAttribute('aria-selected', 'false');
471        });
472        section.querySelectorAll('.wx-river-panel').forEach(function (p) {
473            p.classList.remove('wx-river-panel--active');
474        });
475        btn.classList.add('wx-river-tab--active');
476        btn.setAttribute('aria-selected', 'true');
477
478        var key    = btn.getAttribute('data-river');
479        var siteNo = btn.getAttribute('data-site');
480        var panel  = document.getElementById('wx-river-panel-' + key);
481        if (panel) panel.classList.add('wx-river-panel--active');
482
483        loadRiver(key, siteNo, panel);
484    }
485
486    function loadRiver(key, siteNo, panel) {
487        if (loaded[siteNo]) {
488            /* already loaded — just redraw in case canvas was hidden */
489            var s = states[key];
490            if (s) { s.canvas.style.display = ''; draw(s); }
491            return;
492        }
493        loaded[siteNo] = true;
494
495        var spinner = document.getElementById('wx-river-loading-' + key);
496        var canvas  = document.getElementById('wx-river-canvas-'  + key);
497
498        fetchRiverData(siteNo).then(function (data) {
499            if (spinner) spinner.style.display = 'none';
500            if (!canvas) return;
501            canvas.style.display = '';
502            var s = makeState(canvas, data);
503            states[key] = s;
504            buildLegend(panel, s);
505            bindEvents(s);
506            draw(s);
507            updateReading(key, data.current);
508        }).catch(function () {
509            if (spinner) spinner.textContent = 'Could not load river data.';
510        });
511    }
512
513    /* ── Init ────────────────────────────────────────────────────────────── */
514    document.addEventListener('DOMContentLoaded', function () {
515        var section = document.getElementById('wx-river-section');
516        if (!section) return;
517
518        section.querySelectorAll('.wx-river-tab').forEach(function (btn) {
519            btn.addEventListener('click', function () { activateTab(btn, section); });
520        });
521
522        /* Auto-load the active tab (Animas) on page load */
523        var first = section.querySelector('.wx-river-tab--active');
524        if (first) {
525            loadRiver(
526                first.getAttribute('data-river'),
527                first.getAttribute('data-site'),
528                document.getElementById('wx-river-panel-' + first.getAttribute('data-river'))
529            );
530        }
531    });
532
533}());

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.