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 + '¶meterCd=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 + '¶meterCd=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.