Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="description" content="CN Railroad Snowmelt Map - NOAA/SNODAS snowmelt data visualization"> | |
| <title>CN Snowmelt</title> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <!-- Preconnect to critical origins (saves ~150-230ms) --> | |
| <link rel="preconnect" href="https://api.mapbox.com" crossorigin> | |
| <link rel="preconnect" href="https://events.mapbox.com" crossorigin> | |
| <link rel="dns-prefetch" href="https://api.mapbox.com"> | |
| <!-- Load Mapbox CSS first (smaller, needed for layout) --> | |
| <link href="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css" rel="stylesheet" /> | |
| <!-- Defer Mapbox JS - load async to not block render --> | |
| <script src="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js" defer></script> | |
| <script src="https://unpkg.com/fflate@0.8.2/umd/index.js" defer></script> | |
| <style> | |
| html, body { height: 100%; margin: 0; } | |
| #map { position: absolute; inset: 0; } | |
| /* Loading screen shown immediately */ | |
| .initial-loading { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 200; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| color: #fff; | |
| transition: opacity 0.3s ease-out; | |
| } | |
| .initial-loading.fade-out { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .initial-loading h1 { | |
| font-size: 24px; | |
| margin: 0 0 8px 0; | |
| font-weight: 600; | |
| } | |
| .initial-loading p { | |
| font-size: 14px; | |
| margin: 0 0 24px 0; | |
| opacity: 0.8; | |
| } | |
| .progress-bar { | |
| width: 200px; | |
| height: 4px; | |
| background: rgba(255,255,255,0.2); | |
| border-radius: 2px; | |
| overflow: hidden; | |
| } | |
| .progress-bar-fill { | |
| height: 100%; | |
| background: #4dabf7; | |
| width: 0%; | |
| transition: width 0.3s ease-out; | |
| } | |
| .loading-status { | |
| font-size: 12px; | |
| margin-top: 12px; | |
| opacity: 0.6; | |
| } | |
| .panel-stack{ | |
| position:absolute; | |
| right:14px; | |
| bottom:14px; | |
| display:flex; | |
| flex-direction:column; | |
| gap:6px; | |
| z-index:40; | |
| } | |
| .panel-link{ | |
| width:340px; | |
| background: rgba(255,255,255,0.90); | |
| border-radius: 12px; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.18); | |
| padding: 10px 12px; | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| .panel-link a{ | |
| color:#0b57d0; | |
| text-decoration:none; | |
| font-weight:600; | |
| font-size:14px; | |
| } | |
| .panel-link a:hover{ | |
| text-decoration:underline; | |
| } | |
| .panel { | |
| width: 340px; | |
| background: rgba(255,255,255,0.90); | |
| border-radius: 12px; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.18); | |
| padding: 12px 12px 10px 12px; | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| color: #111; | |
| } | |
| .panel h3 { margin: 0 0 2px 0; font-size: 16px; line-height: 1.2; } | |
| .sub { font-size: 13px; color: #333; margin-bottom: 10px; } | |
| .row { display: flex; align-items: center; gap: 8px; margin: 6px 0; } | |
| .row label { font-size: 14px; } | |
| .loading-spinner { | |
| display: none; | |
| width: 20px; | |
| height: 20px; | |
| border: 3px solid rgba(0,0,0,0.15); | |
| border-top-color: #0b57d0; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| margin-left: 8px; | |
| vertical-align: middle; | |
| } | |
| .loading-spinner.active { display: inline-block; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .pickrow { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin: 8px 0 10px 0; } | |
| select { | |
| width: 100%; | |
| padding: 6px 8px; | |
| border-radius: 8px; | |
| border: 1px solid rgba(0,0,0,0.18); | |
| background: rgba(255,255,255,0.95); | |
| font-size: 13px; | |
| } | |
| select:disabled { opacity: 0.5; } | |
| .sliderrow { display:flex; align-items:center; gap:10px; margin-top:10px; } | |
| input[type="range"] { width: 190px; } | |
| .legend { margin-top: 10px; border-top: 1px solid rgba(0,0,0,0.10); padding-top: 10px; } | |
| .legend-title { font-size: 13px; font-weight: 600; margin: 0 0 6px 0; } | |
| .legend-grid { display: grid; grid-template-columns: 20px 1fr; gap: 6px 10px; } | |
| .swatch { width: 20px; height: 12px; border-radius: 3px; border: 1px solid rgba(0,0,0,0.12); } | |
| .legend-label { font-size: 12px; color: #222; } | |
| .legend-row { | |
| display: flex; | |
| gap: 8px; | |
| align-items: flex-start; /* keep titles aligned at top */ | |
| margin-top: 10px; | |
| } | |
| /* make each legend flexible so they can sit side-by-side */ | |
| .legend { | |
| flex: 1 1 0; | |
| min-width: 120px; /* prevent collapsing at tiny widths */ | |
| max-width: 100%; | |
| box-sizing: border-box; | |
| } | |
| /* keep internal vertical layout within each legend */ | |
| .legend .legend-title { margin-bottom: 6px; } | |
| .legend .legend-grid { grid-template-columns: 20px 1fr; } | |
| .err { margin-top: 8px; font-size: 12px; color: #b00020; display: none; white-space: pre-wrap; } | |
| /* .cursor-readout { | |
| position: absolute; | |
| pointer-events: none; | |
| z-index: 50; | |
| background: rgba(255,255,255,0.92); | |
| border: 1px solid rgba(0,0,0,0.18); | |
| border-radius: 8px; | |
| padding: 4px 6px; | |
| font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| color: #111; | |
| box-shadow: 0 6px 18px rgba(0,0,0,0.18); | |
| transform: translate(10px, 10px); | |
| display: none; | |
| white-space: nowrap; | |
| will-change: transform, left, top; | |
| contain: layout style; | |
| } */ | |
| .cursor-readout { | |
| position: fixed; | |
| left: 0; | |
| top: 0; | |
| pointer-events: none; | |
| z-index: 50; | |
| background: rgba(255,255,255,0.92); | |
| border: 1px solid rgba(0,0,0,0.18); | |
| border-radius: 8px; | |
| padding: 4px 6px; | |
| font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| color: #111; | |
| box-shadow: 0 6px 18px rgba(0,0,0,0.18); | |
| display: none; | |
| white-space: nowrap; | |
| will-change: transform; | |
| contain: layout style; | |
| } | |
| /* Accessibility improvements */ | |
| .sr-only { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: nowrap; | |
| border: 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Immediate loading screen (no JS required) --> | |
| <div id="initialLoading" class="initial-loading"> | |
| <h1>CN Snowmelt Map</h1> | |
| <p>Loading NOAA/SNODAS data...</p> | |
| <div class="progress-bar"> | |
| <div id="progressFill" class="progress-bar-fill"></div> | |
| </div> | |
| <div id="loadingStatus" class="loading-status">Initializing...</div> | |
| </div> | |
| <div id="map"></div> | |
| <div id="cursorReadout" class="cursor-readout"></div> | |
| <div class="panel-stack"> | |
| <div class="panel-link"> | |
| <a href="https://accuweather.atlassian.net/wiki/spaces/AO/pages/3596288537/Snowmelt" id="confluenceLink" target="_blank" rel="noopener">Snowmelt Confluence</a> | |
| </div> | |
| <div class="panel" id="mainPanel"> | |
| <h3>CN Snowmelt</h3> | |
| <div class="sub" id="subtitle">Loading…</div> | |
| <div class="pickrow"> | |
| <label class="sr-only" for="yearSel">Year</label> | |
| <select id="yearSel" aria-label="Year"></select> | |
| <label class="sr-only" for="monthSel">Month</label> | |
| <select id="monthSel" aria-label="Month"></select> | |
| <label class="sr-only" for="daySel">Day</label> | |
| <select id="daySel" aria-label="Day"></select> | |
| </div> | |
| <div class="row"> | |
| <input type="radio" name="hours" id="h24" value="24" checked> | |
| <label for="h24">24 Hour Snowmelt</label> | |
| </div> | |
| <div class="row"> | |
| <input type="radio" name="hours" id="h72" value="72"> | |
| <label for="h72">72 Hour Snowmelt</label> | |
| </div> | |
| <div class="sliderrow"> | |
| <label for="opacity" style="font-size:13px;">Opacity</label> | |
| <input id="opacity" type="range" min="0" max="1" step="0.01" value="0.65" aria-label="Snowmelt layer opacity" /> | |
| <div id="opv" style="font-size:12px;color:#333;width:40px;text-align:right;" aria-live="polite"></div> | |
| </div> | |
| <div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.10);"> | |
| <div class="row"> | |
| <input type="checkbox" id="qpfToggle"> | |
| <label for="qpfToggle" style="font-size: 14px;">WPC QPF</label> | |
| </div> | |
| <div class="row" id="qpfDayRow" style="display: none; margin-left: 20px;"> | |
| <input type="radio" name="qpfDay" id="qpfDay2" value="2" checked> | |
| <label for="qpfDay2">Day 2 (24hr)</label> | |
| <input type="radio" name="qpfDay" id="qpfDay3" value="3" style="margin-left: 10px;"> | |
| <label for="qpfDay3">Day 3 (48hr)</label> | |
| </div> | |
| <div class="row" id="qpfOpacityRow" style="display: none; margin-left: 20px; margin-top: 5px;"> | |
| <label for="qpfOpacity" style="font-size:12px;">QPF Opacity</label> | |
| <input id="qpfOpacity" type="range" min="0" max="1" step="0.05" value="0.70" style="width: 100px;" /> | |
| </div> | |
| </div> | |
| <div style="margin-top: 8px; font-size: 11px; font-weight: 700; color: #b00020; white-space: normal;"> | |
| Mouse readouts will only work within 20 miles of track, when zoomed in, and when melt is > trace | |
| </div> | |
| <div class="legend-row"> | |
| <div class="legend"> | |
| <div class="legend-title" id="legendTitle">Snowmelt - Inches of water equivalent</div> | |
| <div class="legend-grid" id="legendGrid" role="list"></div> | |
| </div> | |
| <div class="legend" id="qpfLegend" style="display: none; flex-direction: column;"> | |
| <div class="legend-title">QPF - Inches of Precipitation</div> | |
| <div class="legend-grid" id="qpfLegendGrid" role="list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="/config.js"></script> | |
| <script> | |
| // Wait for deferred scripts to load | |
| // function waitForMapbox(callback) { | |
| // if (typeof mapboxgl !== 'undefined') { | |
| // callback(); | |
| // } else { | |
| // setTimeout(() => waitForMapbox(callback), 50); | |
| // } | |
| // } | |
| // waitForMapbox(function() { | |
| // 'use strict'; | |
| document.addEventListener('DOMContentLoaded', function() { | |
| 'use strict'; | |
| const progressEl = document.getElementById('progressFill'); | |
| const statusEl = document.getElementById('loadingStatus'); | |
| const initialLoadingEl = document.getElementById('initialLoading'); | |
| let loadProgress = 0; | |
| function setProgress(percent, status) { | |
| loadProgress = Math.max(loadProgress, percent); | |
| if (progressEl) progressEl.style.width = loadProgress + '%'; | |
| if (statusEl && status) statusEl.textContent = status; | |
| } | |
| function hideInitialLoading() { | |
| if (initialLoadingEl) { | |
| initialLoadingEl.classList.add('fade-out'); | |
| setTimeout(() => { | |
| initialLoadingEl.style.display = 'none'; | |
| }, 300); | |
| } | |
| } | |
| setProgress(10, 'Loading map...'); | |
| const errBox = document.getElementById("err"); | |
| const subtitle = document.getElementById("subtitle"); | |
| const yearSel = document.getElementById("yearSel"); | |
| const monthSel = document.getElementById("monthSel"); | |
| const daySel = document.getElementById("daySel"); | |
| const legendGrid = document.getElementById("legendGrid"); | |
| const OPACITY = document.getElementById("opacity"); | |
| const OPV = document.getElementById("opv"); | |
| const cursorEl = document.getElementById("cursorReadout"); | |
| let currentGeneration = 0; | |
| function nextGeneration() { | |
| currentGeneration++; | |
| return currentGeneration; | |
| } | |
| function isCurrentGeneration(gen) { | |
| return gen === currentGeneration; | |
| } | |
| document.getElementById("qpfToggle").addEventListener("change", (e) => { | |
| const visible = e.target.checked; | |
| document.getElementById("qpfDayRow").style.display = visible ? "flex" : "none"; | |
| toggleQpf(visible, qpfDay); | |
| }); | |
| document.querySelectorAll('input[name="qpfDay"]').forEach(el => { | |
| el.addEventListener("change", (e) => { | |
| qpfDay = parseInt(e.target.value, 10); | |
| if (qpfLayerVisible) toggleQpf(true, qpfDay); | |
| }); | |
| }); | |
| document.getElementById("qpfOpacity").addEventListener("input", (e) => { | |
| const opacity = parseFloat(e.target.value); | |
| if (map.getLayer("qpf_wms_layer")) { | |
| map.setPaintProperty("qpf_wms_layer", "raster-opacity", opacity); | |
| } | |
| }); | |
| const DOM = "zz"; | |
| const TRACE_INCHES = 0.02; | |
| const MIN_ZOOM_FOR_READOUT = 8; | |
| const CURSOR_ZMIN = 8; | |
| const PREFETCH_ZMIN = 10; | |
| let selGen = 0; | |
| let lastRasterSrcId = null; | |
| let lastRasterLayerId = null; | |
| let lastSetYmd = null; | |
| let lastSetHours = null; | |
| let rawPrefetchTimer = null; | |
| const INITIAL_BOUNDS = [[-130.359201, 29.915607], [-63.228204, 60.840431]]; | |
| let readoutsEnabled = false, lastKey = ""; | |
| const GRID_RESOLUTION = 12; | |
| const READOUT_THROTTLE_MS = 200; // Minimum ms between readout updates | |
| const PREFETCH_THROTTLE_MS = 350; // Minimum ms between prefetch batches | |
| const MAX_CONCURRENT_RAW_FETCHES = 4; // Limit simultaneous raw tile fetches | |
| const READOUT_DEBOUNCE_MS = 50; // Debounce rapid mouse movements | |
| const WPC_QPF_WMS_BASE = "https://mapservices.weather.noaa.gov/vector/services/precip/wpc_qpf/MapServer/WMSServer"; | |
| let qpfLayerVisible = false; | |
| let qpfDay = 2; // 2 or 3 | |
| const cfg = window.__SNODAS_CONFIG__ || {}; | |
| window.__SNODAS_CONFIG__ = cfg; | |
| if (!cfg.VALUE_BASE) cfg.VALUE_BASE = window.location.origin; | |
| if (cfg.USE_HF_TILES == null) cfg.USE_HF_TILES = false; | |
| if (!cfg.HF_DATASET_REPO) cfg.HF_DATASET_REPO = "Jsinowitz/snodas-snowmelt-cache"; | |
| const CLIENT_ID = (() => { | |
| try { | |
| const k = "snodas_client_id"; | |
| const v = localStorage.getItem(k); | |
| if (v) return v; | |
| const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now().toString(16) + Math.random().toString(16).slice(2)); | |
| localStorage.setItem(k, id); | |
| return id; | |
| } catch { | |
| return (Date.now().toString(16) + Math.random().toString(16).slice(2)); | |
| } | |
| })(); | |
| function getQpfWmsUrl(day) { | |
| // WMS 24 = "QPF 24 Hour Day 2", WMS 23 = "QPF 24 Hour Day 3" | |
| const layer = day === 2 ? "24" : "23"; | |
| return `${WPC_QPF_WMS_BASE}?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&FORMAT=image/png&TRANSPARENT=true&LAYERS=${layer}&WIDTH=256&HEIGHT=256&CRS=EPSG:3857&STYLES=&BBOX={bbox-epsg-3857}`; | |
| } | |
| const QPF_LEGEND = { | |
| colors: [ | |
| "#ffffff", | |
| "#7fff00", | |
| "#00ff00", | |
| "#088b00", | |
| "#104e8b", | |
| "#1e90ff", | |
| "#00b2ee", | |
| "#00eeee", | |
| "#8968cd", | |
| "#912cee", | |
| "#8b008b", | |
| "#8b0000", | |
| "#ff0000", | |
| "#ee4000", | |
| "#ff7f00", | |
| "#ce8500", | |
| "#ffd700", | |
| "#ffff00", | |
| "#ffc0b7", | |
| ], | |
| labels: [ | |
| "0.01", "0.10", "0.25", "0.50", "0.75", | |
| "1.00", "1.25", "1.50", "1.75", "2.00", | |
| "2.50", "3.00", "4.00", "5.00", "7.00", | |
| "10.00", "15.00", "20.00" | |
| ] | |
| }; | |
| function renderQpfLegend() { | |
| const grid = document.getElementById("qpfLegendGrid"); | |
| if (!grid) return; | |
| const fragment = document.createDocumentFragment(); | |
| for (let i = 0; i < QPF_LEGEND.colors.length - 1; i++) { | |
| const sw = document.createElement("div"); | |
| sw.className = "swatch"; | |
| sw.style.background = QPF_LEGEND.colors[i + 1]; | |
| sw.setAttribute("role", "listitem"); | |
| const lb = document.createElement("div"); | |
| lb.className = "legend-label"; | |
| lb.textContent = QPF_LEGEND.labels[i] || ""; | |
| fragment.appendChild(sw); | |
| fragment.appendChild(lb); | |
| } | |
| grid.innerHTML = ""; | |
| grid.appendChild(fragment); | |
| } | |
| function addQpfLayer() { | |
| const srcId = "qpf_wms_src"; | |
| const layerId = "qpf_wms_layer"; | |
| // Remove existing if present | |
| if (map.getLayer(layerId)) map.removeLayer(layerId); | |
| if (map.getSource(srcId)) map.removeSource(srcId); | |
| if (!qpfLayerVisible) return; | |
| map.addSource(srcId, { | |
| type: "raster", | |
| tiles: [getQpfWmsUrl(qpfDay)], | |
| tileSize: 256, | |
| attribution: "QPF: NOAA/NWS Weather Prediction Center" | |
| }); | |
| // Add ON TOP - no second argument means it goes on top of everything | |
| map.addLayer({ | |
| id: layerId, | |
| type: "raster", | |
| source: srcId, | |
| paint: { | |
| "raster-opacity": 0.65, | |
| "raster-saturation": -0.25, | |
| "raster-contrast": 0.15, | |
| "raster-fade-duration": 0 | |
| } | |
| }); | |
| // Note: removed the second argument so it goes on top | |
| } | |
| function toggleQpf(visible, day) { | |
| qpfLayerVisible = visible; | |
| qpfDay = day || qpfDay; | |
| addQpfLayer(); | |
| // Show/hide QPF legend and opacity slider | |
| const qpfLegendEl = document.getElementById("qpfLegend"); | |
| const qpfOpacityRow = document.getElementById("qpfOpacityRow"); | |
| if (qpfLegendEl) { | |
| qpfLegendEl.style.display = visible ? "flex" : "none"; | |
| if (visible) renderQpfLegend(); | |
| } | |
| if (qpfOpacityRow) { | |
| qpfOpacityRow.style.display = visible ? "flex" : "none"; | |
| } | |
| } | |
| function fetchWithTimeout(url, opts, ms) { | |
| const timeoutCtrl = new AbortController(); | |
| const t = setTimeout(() => timeoutCtrl.abort("timeout"), ms); | |
| const userSignal = opts && opts.signal ? opts.signal : null; | |
| let signal = timeoutCtrl.signal; | |
| if (userSignal) { | |
| const combo = new AbortController(); | |
| const onAbort = () => combo.abort("aborted"); | |
| userSignal.addEventListener("abort", onAbort, { once: true }); | |
| timeoutCtrl.signal.addEventListener("abort", onAbort, { once: true }); | |
| signal = combo.signal; | |
| } | |
| const merged = Object.assign({}, opts || {}, { signal }); | |
| return fetch(url, merged).finally(() => clearTimeout(t)); | |
| } | |
| function showErr(msg) { errBox.style.display = "block"; errBox.textContent = msg; } | |
| function z2(n) { return (n < 10 ? "0" : "") + n; } | |
| const token = (window.__SNODAS_CONFIG__ && window.__SNODAS_CONFIG__.MAPBOX_TOKEN) || ""; | |
| if (!token) { showErr("Missing MAPBOX_TOKEN. Check /config.js and server environment."); return; } | |
| mapboxgl.accessToken = token; | |
| setProgress(20, 'Creating map...'); | |
| const map = new mapboxgl.Map({ | |
| container: "map", | |
| style: "mapbox://styles/mapbox/satellite-streets-v12", | |
| bounds: INITIAL_BOUNDS, | |
| fitBoundsOptions: { padding: 40, duration: 0, maxZoom: 7 }, | |
| fadeDuration: 0, | |
| trackResize: true, | |
| preserveDrawingBuffer: false, | |
| antialias: false, | |
| refreshExpiredTiles: false | |
| }); | |
| if (typeof map.isPitching !== "function") map.isPitching = () => false; | |
| if (typeof map.isRotating !== "function") map.isRotating = () => false; | |
| const VALUE_BASE = (window.__SNODAS_CONFIG__ && window.__SNODAS_CONFIG__.VALUE_BASE) || window.location.origin; | |
| // function rawTileUrl(z, x, y, ymd, hours) { | |
| // return `/tiles/forecast_raw/by_date/${z}/${x}/${y}.npz?date_yyyymmdd=${encodeURIComponent(ymd)}&hours=${encodeURIComponent(hours)}&dom=${encodeURIComponent(DOM)}&client_id=${encodeURIComponent(CLIENT_ID)}&gen=${encodeURIComponent(String(selGen))}`; | |
| // } | |
| // function tileUrl(z, x, y, ymd, hours) { | |
| // return `/tiles/forecast/by_date/${z}/${x}/${y}.png?date_yyyymmdd=${encodeURIComponent(ymd)}&hours=${encodeURIComponent(hours)}&dom=${encodeURIComponent(DOM)}&client_id=${encodeURIComponent(CLIENT_ID)}&gen=${encodeURIComponent(String(selGen))}`; | |
| // } | |
| function rawTileUrl(z, x, y, ymd, hours) { | |
| return `/tiles/forecast_raw/by_date/${z}/${x}/${y}.npz?date_yyyymmdd=${encodeURIComponent(ymd)}&hours=${encodeURIComponent(hours)}&dom=${encodeURIComponent(DOM)}&client_id=${encodeURIComponent(CLIENT_ID)}&gen=${encodeURIComponent(String(currentGeneration))}`; | |
| } | |
| function tileUrl(z, x, y, ymd, hours) { | |
| return `/tiles/forecast/by_date/${z}/${x}/${y}.png?date_yyyymmdd=${encodeURIComponent(ymd)}&hours=${encodeURIComponent(hours)}&dom=${encodeURIComponent(DOM)}&client_id=${encodeURIComponent(CLIENT_ID)}&gen=${encodeURIComponent(String(currentGeneration))}`; | |
| } | |
| const CN_LINE_GLOW = "cn_tracks_src_line_glow", CN_LINE = "cn_tracks_src_line"; | |
| const LAYER_ID = "melt_layer", SRC_ID = "melt_src"; | |
| function tracksReady() { return !!(map.getLayer(CN_LINE) || map.getLayer(CN_LINE_GLOW)); } | |
| const TRACK_CORRIDOR_MILES = 20; | |
| const TRACK_CORRIDOR_PX_CAP = 200; | |
| function milesToPixels(miles, latDeg, zoom) { | |
| const meters = miles * 1609.344; | |
| const lat = latDeg * Math.PI / 180; | |
| const metersPerPixel = 156543.03392 * Math.cos(lat) / Math.pow(2, zoom); | |
| if (!isFinite(metersPerPixel) || metersPerPixel <= 0) return TRACK_CORRIDOR_PX_CAP; | |
| return meters / metersPerPixel; | |
| } | |
| function nearTrack(point, lngLat) { | |
| // At high zoom, tracks may not be rendered - skip this check | |
| if (map.getZoom() > 10) return true; | |
| const layers = []; | |
| if (map.getLayer(CN_LINE)) layers.push(CN_LINE); | |
| if (map.getLayer(CN_LINE_GLOW)) layers.push(CN_LINE_GLOW); | |
| if (map.getLayer("cn_fix_line")) layers.push("cn_fix_line"); | |
| if (map.getLayer("cn_fix_glow")) layers.push("cn_fix_glow"); | |
| if (!layers.length) return true; // If no track layers, allow readout everywhere | |
| const z = map.getZoom(); | |
| const rRaw = milesToPixels(TRACK_CORRIDOR_MILES, lngLat.lat, z); | |
| const r = Math.max(6, Math.min(TRACK_CORRIDOR_PX_CAP, Math.round(rRaw))); | |
| const bbox = [[point.x - r, point.y - r], [point.x + r, point.y + r]]; | |
| const feats = map.queryRenderedFeatures(bbox, { layers }); | |
| return !!(feats && feats.length); | |
| } | |
| function hideCountryLabels(map) { | |
| const style = map.getStyle(); | |
| if (!style || !style.layers) return; | |
| for (const layer of style.layers) { | |
| const id = (layer.id || "").toLowerCase(); | |
| // Match country and continent labels | |
| if (id.includes("country-label") || | |
| id.includes("continent-label") || | |
| id.includes("state-label")) { | |
| map.setLayoutProperty(layer.id, "visibility", "none"); | |
| } | |
| } | |
| } | |
| function muteRoads(map) { | |
| const style = map.getStyle(); if (!style || !style.layers) return; | |
| const roadLike = l => { | |
| const id = (l.id || "").toLowerCase(), sl = (l["source-layer"] || "").toLowerCase(); | |
| return id.includes("road") || id.includes("street") || id.includes("highway") || | |
| id.includes("bridge") || id.includes("tunnel") || id.includes("motorway") || | |
| sl.includes("road") || sl.includes("street") || sl.includes("transportation"); | |
| }; | |
| for (const l of style.layers) { | |
| if (!roadLike(l)) continue; | |
| if (l.type === "line") { | |
| if (map.getPaintProperty(l.id, "line-color") != null) map.setPaintProperty(l.id, "line-color", "#c7c7c7"); | |
| if (map.getPaintProperty(l.id, "line-opacity") != null) map.setPaintProperty(l.id, "line-opacity", 0.40); | |
| if (map.getPaintProperty(l.id, "line-width") != null) { | |
| const w = map.getPaintProperty(l.id, "line-width"); | |
| if (typeof w === "number") map.setPaintProperty(l.id, "line-width", Math.max(0.5, w * 0.8)); | |
| } | |
| } | |
| if (l.type === "fill") { | |
| if (map.getPaintProperty(l.id, "fill-color") != null) map.setPaintProperty(l.id, "fill-color", "#d0d0d0"); | |
| if (map.getPaintProperty(l.id, "fill-opacity") != null) map.setPaintProperty(l.id, "fill-opacity", 0.20); | |
| } | |
| } | |
| } | |
| function lon2tile(lon, z) { return Math.floor(((lon + 180) / 360) * (1 << z)); } | |
| function lat2tile(lat, z) { const r = lat * Math.PI / 180, n = Math.log(Math.tan(Math.PI / 4 + r / 2)); return Math.floor((1 - n / Math.PI) / 2 * (1 << z)); } | |
| function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); } | |
| function tileRangeForBounds(b, z) { | |
| const w = b.getWest(), s = b.getSouth(), e = b.getEast(), n = b.getNorth(), max = (1 << z) - 1; | |
| let x0 = lon2tile(w, z), x1 = lon2tile(e, z), y0 = lat2tile(n, z), y1 = lat2tile(s, z); | |
| x0 = clamp(x0, 0, max); x1 = clamp(x1, 0, max); y0 = clamp(y0, 0, max); y1 = clamp(y1, 0, max); | |
| const xmin = Math.min(x0, x1), xmax = Math.max(x0, x1), ymin = Math.min(y0, y1), ymax = Math.max(y0, y1); | |
| return { xmin, xmax, ymin, ymax }; | |
| } | |
| function makeQueue(limit) { | |
| let active = 0; | |
| const q = []; | |
| let abortController = new AbortController(); | |
| const run = () => { | |
| while (active < limit && q.length) { | |
| const { fn, gen } = q.shift(); | |
| // Skip if this task is from an old generation | |
| if (gen !== undefined && !isCurrentGeneration(gen)) { | |
| continue; | |
| } | |
| active++; | |
| Promise.resolve() | |
| .then(() => { | |
| // Double-check generation before executing | |
| if (gen !== undefined && !isCurrentGeneration(gen)) { | |
| return null; | |
| } | |
| return fn(abortController.signal); | |
| }) | |
| .catch(() => {}) | |
| .finally(() => { | |
| active--; | |
| run(); | |
| }); | |
| } | |
| }; | |
| const push = (fn, gen) => { | |
| q.push({ fn, gen: gen !== undefined ? gen : currentGeneration }); | |
| run(); | |
| }; | |
| push.clear = () => { | |
| q.length = 0; | |
| }; | |
| push.abort = () => { | |
| q.length = 0; | |
| abortController.abort(); | |
| abortController = new AbortController(); | |
| }; | |
| push.getSignal = () => abortController.signal; | |
| return push; | |
| } | |
| const prefetchPush = makeQueue(16); | |
| const prefetched = new Set(); | |
| function prefetchViewportTiles() { | |
| if (map.getZoom() < PREFETCH_ZMIN) return; | |
| const ymd = selectedDateYMD(); | |
| if (!ymd) return; | |
| const hours = getHours(); | |
| const gen = currentGeneration; // Capture current generation | |
| const z0 = Math.max(0, Math.floor(map.getZoom())); | |
| const maxZ = Math.min(12, z0 + 3); | |
| const b = map.getBounds(); | |
| for (let z = z0 + 1; z <= maxZ; z++) { | |
| const r = tileRangeForBounds(b, z); | |
| for (let x = r.xmin; x <= r.xmax; x++) { | |
| for (let y = r.ymin; y <= r.ymax; y++) { | |
| const u = tileUrl(z, x, y, ymd, hours); | |
| if (prefetched.has(u)) continue; | |
| prefetched.add(u); | |
| prefetchPush((signal) => { | |
| if (!isCurrentGeneration(gen)) return Promise.resolve(); | |
| return fetch(u, { | |
| cache: "force-cache", | |
| signal: signal || prefetchPush.getSignal() | |
| }).catch(() => {}); | |
| }, gen); | |
| } | |
| } | |
| } | |
| } | |
| function debounce(fn, ms) { let t = 0; return () => { clearTimeout(t); t = setTimeout(fn, ms); }; } | |
| const prefetchDebounced = debounce(prefetchViewportTiles, 250); | |
| function throttle(fn, ms) { | |
| let lastCall = 0; | |
| let scheduled = null; | |
| return function(...args) { | |
| const now = Date.now(); | |
| const remaining = ms - (now - lastCall); | |
| if (remaining <= 0) { | |
| lastCall = now; | |
| fn.apply(this, args); | |
| } else if (!scheduled) { | |
| scheduled = setTimeout(() => { | |
| lastCall = Date.now(); | |
| scheduled = null; | |
| fn.apply(this, args); | |
| }, remaining); | |
| } | |
| }; | |
| } | |
| // map.on("load", () => { | |
| // muteRoads(map); | |
| // hideCountryLabels(map); | |
| // }); | |
| // map.on("styledata", () => { | |
| // muteRoads(map); | |
| // hideCountryLabels(map); | |
| // }); | |
| function applyBasemapMutingOnce() { | |
| muteRoads(map); | |
| hideCountryLabels(map); | |
| } | |
| const LEGENDS = { | |
| "24": { | |
| colors: ["#dcdcdc", "#990000", "#cc3333", "#ff6666", "#cccc66", "#ff8c00", "#ffff66", "#99ff99", "#00ff00", "#66cc66", "#6666ff", "#3333cc", "#000099", "#990099", "#4b0082", "#2f004f", "#000000"], | |
| labels: ["0.00 to trace", "trace to 0.02", "0.02 to 0.04", "0.04 to 0.08", "0.08 to 0.12", "0.12 to 0.16", "0.16 to 0.20", "0.20 to 0.39", "0.39 to 0.59", "0.59 to 0.79", "0.79 to 0.98", "0.98 to 1.4", "1.4 to 2.0", "2.0 to 3.0", "3.0 to 4.0", "4.0 to 5.0", "> 5.0"] | |
| }, | |
| "72": { | |
| colors: ["#dcdcdc", "#990000", "#cc3333", "#ff6666", "#cccc66", "#ff8c00", "#ffff66", "#99ff99", "#00ff00", "#66cc66", "#6666ff", "#3333cc", "#000099", "#990099", "#4b0082", "#2f004f", "#000000"], | |
| labels: ["0.00 to trace", "trace to 0.04", "0.04 to 0.08", "0.08 to 0.16", "0.16 to 0.24", "0.24 to 0.31", "0.31 to 0.39", "0.39 to 0.79", "0.79 to 1.4", "1.4 to 2.0", "2.0 to 3.0", "3.0 to 3.9", "3.9 to 5.0", "5.0 to 6.5", "6.5 to 8.0", "8.0 to 9.8", "> 9.8"] | |
| } | |
| }; | |
| function renderLegend(hours) { | |
| const L = LEGENDS[String(hours)] || LEGENDS["24"]; | |
| const fragment = document.createDocumentFragment(); | |
| for (let i = 0; i < L.colors.length; i++) { | |
| const sw = document.createElement("div"); | |
| sw.className = "swatch"; | |
| sw.style.background = L.colors[i]; | |
| sw.setAttribute("role", "listitem"); | |
| const lb = document.createElement("div"); | |
| lb.className = "legend-label"; | |
| lb.textContent = L.labels[i] || ""; | |
| fragment.appendChild(sw); | |
| fragment.appendChild(lb); | |
| } | |
| legendGrid.innerHTML = ""; | |
| legendGrid.appendChild(fragment); | |
| } | |
| function getHours() { const el = document.querySelector('input[name="hours"]:checked'); return el ? parseInt(el.value, 10) : 24; } | |
| function selectedDateYMD() { const y = yearSel.value, m = monthSel.value, d = daySel.value; if (!y || !m || !d) return null; return `${y}${m}${d}`; } | |
| function subtitleText(yyyymmdd, hours) { const y = yyyymmdd.slice(0, 4), m = yyyymmdd.slice(4, 6), d = yyyymmdd.slice(6, 8); return `${hours} Hour snowmelt ending on ${y}-${m}-${d} 12Z`; } | |
| function addCnTracksVectorTiles() { | |
| const SRC = "cn_tracks_vt"; | |
| if (map.getSource(SRC)) return; | |
| map.addSource(SRC, { | |
| type: "vector", | |
| tiles: [`${VALUE_BASE}/cn_tiles/{z}/{x}/{y}.pbf`], | |
| tileSize: 512, | |
| minzoom: 0, | |
| maxzoom: 7, | |
| bounds: [-170, 29, -63, 90] | |
| }); | |
| if (!map.getLayer(CN_LINE_GLOW)) { | |
| map.addLayer({ | |
| id: CN_LINE_GLOW, | |
| type: "line", | |
| source: SRC, | |
| "source-layer": "cn_tracks", | |
| minzoom: 0, | |
| maxzoom: 24, | |
| layout: { "line-join": "round", "line-cap": "round" }, | |
| paint: { | |
| "line-color": "#ff0000", | |
| "line-opacity": 0.85, | |
| "line-blur": 2.0, | |
| "line-width": ["interpolate", ["linear"], ["zoom"], 4, 2.0, 6, 4.0, 8, 6.0, 10, 8.0, 12, 10.0, 14, 12.0] | |
| } | |
| }); | |
| } | |
| if (!map.getLayer(CN_LINE)) { | |
| map.addLayer({ | |
| id: CN_LINE, | |
| type: "line", | |
| source: SRC, | |
| "source-layer": "cn_tracks", | |
| minzoom: 0, | |
| maxzoom: 24, | |
| layout: { "line-join": "round", "line-cap": "round" }, | |
| paint: { | |
| "line-color": "#ffffff", | |
| "line-opacity": 0.95, | |
| "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 6, 1.6, 8, 2.4, 10, 3.2, 12, 4.0, 14, 4.8] | |
| } | |
| }); | |
| } | |
| // Manual track corrections — missing segments | |
| const TRACK_FIXES_SRC = "cn_track_fixes"; | |
| if (!map.getSource(TRACK_FIXES_SRC)) { | |
| map.addSource(TRACK_FIXES_SRC, { | |
| type: "geojson", | |
| data: { | |
| type: "FeatureCollection", | |
| features: [ | |
| { type: "Feature", geometry: { type: "LineString", coordinates: [ | |
| [-103.715, 52.884], [-103.659, 52.882], [-103.592, 52.870], [-102.429, 52.863] | |
| ]}}, | |
| { type: "Feature", geometry: { type: "LineString", coordinates: [ | |
| [-79.557, 44.120], [-79.540, 44.290], [-79.685, 44.373] | |
| ]}} | |
| ] | |
| } | |
| }); | |
| map.addLayer({ | |
| id: "cn_fix_glow", type: "line", source: TRACK_FIXES_SRC, | |
| layout: { "line-join": "round", "line-cap": "round" }, | |
| paint: { | |
| "line-color": "#ff0000", "line-opacity": 0.85, "line-blur": 2.0, | |
| "line-width": ["interpolate", ["linear"], ["zoom"], 4, 2.0, 6, 4.0, 8, 6.0, 10, 8.0, 12, 10.0, 14, 12.0] | |
| } | |
| }); | |
| map.addLayer({ | |
| id: "cn_fix_line", type: "line", source: TRACK_FIXES_SRC, | |
| layout: { "line-join": "round", "line-cap": "round" }, | |
| paint: { | |
| "line-color": "#ffffff", "line-opacity": 0.95, | |
| "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 6, 1.6, 8, 2.4, 10, 3.2, 12, 4.0, 14, 4.8] | |
| } | |
| }); | |
| } | |
| } | |
| function setOpacity(v) { | |
| OPV.textContent = v.toFixed(2); | |
| if (lastRasterLayerId && map.getLayer(lastRasterLayerId)) | |
| {map.setPaintProperty(lastRasterLayerId, "raster-opacity", parseFloat(OPACITY.value));} | |
| } | |
| function setTilesForSelection() { | |
| const ymd = selectedDateYMD(); | |
| const hours = getHours(); | |
| // Only nuke raw cache if date/hours actually changed | |
| const dateChanged = (ymd !== lastSetYmd || hours !== lastSetHours); | |
| lastSetYmd = ymd; | |
| lastSetHours = hours; | |
| // INCREMENT GENERATION FIRST - this invalidates all old requests | |
| const gen = nextGeneration(); | |
| // *** IMMEDIATELY notify server of new generation *** | |
| // Use sendBeacon for fire-and-forget speed (doesn't wait for response) | |
| // Falls back to fetch if sendBeacon is unavailable | |
| const payload = JSON.stringify({ | |
| client_id: CLIENT_ID, | |
| date_yyyymmdd: ymd || "00000000", | |
| gen: gen, | |
| }); | |
| try { | |
| if (navigator.sendBeacon) { | |
| const blob = new Blob([payload], { type: "application/json" }); | |
| navigator.sendBeacon(`${VALUE_BASE}/ui/active_date`, blob); | |
| } else { | |
| // Fallback: fire-and-forget fetch | |
| fetch(`${VALUE_BASE}/ui/active_date`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: payload, | |
| keepalive: true, | |
| }).catch(() => {}); | |
| } | |
| } catch {} | |
| // Abort all outstanding requests | |
| prefetchPush.abort(); | |
| prefetchRawPush.abort(); | |
| abortAllRawTileRequests(); | |
| // Reset tracking sets | |
| prefetched.clear(); | |
| if (dateChanged) { | |
| rawTileCache.clear(); | |
| rawPrefetched.clear(); | |
| } | |
| // Clear any pending timers | |
| if (rawPrefetchTimer) { | |
| clearTimeout(rawPrefetchTimer); | |
| rawPrefetchTimer = null; | |
| } | |
| // Reset hover state | |
| hoverSeq++; | |
| lastHoverKey = ""; | |
| showCursorReadout(0, 0, ""); | |
| if (!ymd) return; | |
| // Also do the normal notifyActiveDate (with AbortController tracking) | |
| // notifyActiveDate(ymd); | |
| subtitle.textContent = subtitleText(ymd, hours); | |
| renderLegend(hours); | |
| // *** Bake gen into the tile URL template *** | |
| // This is the key change: every tile Mapbox fetches from this source | |
| // will include the current generation, so the server can instantly | |
| // reject tiles from old generations. | |
| const tiles = [ | |
| `/tiles/forecast/by_date/{z}/{x}/{y}.png?date_yyyymmdd=${encodeURIComponent(ymd)}&hours=${encodeURIComponent(hours)}&dom=${encodeURIComponent(DOM)}&client_id=${encodeURIComponent(CLIENT_ID)}&gen=${encodeURIComponent(String(gen))}` | |
| ]; | |
| const srcId = `${SRC_ID}_${gen}`; | |
| const layerId = `${LAYER_ID}_${gen}`; | |
| // Use requestAnimationFrame for smoother visual update | |
| requestAnimationFrame(() => { | |
| // Remove OLD layer/source first (before adding new) | |
| if (lastRasterLayerId && map.getLayer(lastRasterLayerId)) { | |
| try { map.removeLayer(lastRasterLayerId); } catch {} | |
| } | |
| if (lastRasterSrcId && map.getSource(lastRasterSrcId)) { | |
| try { map.removeSource(lastRasterSrcId); } catch {} | |
| } | |
| map.addSource(srcId, { | |
| type: "raster", | |
| tiles: tiles, | |
| tileSize: 256, | |
| maxzoom: 12, | |
| bounds: [-170, 29, -40, 90], | |
| }); | |
| map.addLayer({ | |
| id: layerId, | |
| type: "raster", | |
| source: srcId, | |
| paint: { | |
| "raster-opacity": parseFloat(OPACITY.value), | |
| "raster-fade-duration": 0, | |
| "raster-contrast": 0.20, | |
| "raster-saturation": 0.05, | |
| }, | |
| }); | |
| // Ensure proper layer ordering | |
| if (map.getLayer(CN_LINE_GLOW)) { | |
| try { map.moveLayer(layerId, CN_LINE_GLOW); } catch {} | |
| } | |
| if (map.getLayer("terrain-hillshade")) { | |
| try { map.moveLayer("terrain-hillshade", layerId); } catch {} | |
| } | |
| lastRasterSrcId = srcId; | |
| lastRasterLayerId = layerId; | |
| }); | |
| // Start prefetching visible tiles for the NEW selection | |
| prefetchViewportTiles(); | |
| // Delay raw tile prefetch to let visual tiles load first | |
| if ('requestIdleCallback' in window) { | |
| rawPrefetchTimer = requestIdleCallback( | |
| () => { | |
| if (isCurrentGeneration(gen)) { | |
| prefetchViewportRawTiles(); | |
| } | |
| }, | |
| { timeout: 2000 } | |
| ); | |
| } else { | |
| rawPrefetchTimer = setTimeout(() => { | |
| if (isCurrentGeneration(gen)) { | |
| prefetchViewportRawTiles(); | |
| } | |
| }, 1500); | |
| } | |
| } | |
| function setSelectOptions(sel, options, value, disabledSet) { | |
| sel.innerHTML = ""; | |
| for (const opt of options) { | |
| const o = document.createElement("option"); | |
| o.value = opt.value; o.textContent = opt.label; | |
| if (disabledSet && disabledSet.has(opt.value)) o.disabled = true; | |
| sel.appendChild(o); | |
| } | |
| if (value) sel.value = value; | |
| } | |
| function monthName(m) { | |
| const names = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; | |
| return names[parseInt(m, 10) - 1] || m; | |
| } | |
| let available = [], availableSet = new Set(); | |
| function recomputeDayOptions() { | |
| const y = yearSel.value, m = monthSel.value; | |
| if (!y || !m) return; | |
| // Get the actual number of days in this month | |
| const daysInMonth = new Date(parseInt(y, 10), parseInt(m, 10), 0).getDate(); | |
| const days = []; | |
| const disabled = new Set(); | |
| for (let dd = 1; dd <= daysInMonth; dd++) { | |
| const d2 = z2(dd); | |
| const ymd = `${y}${m}${d2}`; | |
| days.push({ value: d2, label: String(dd) }); | |
| // Only disable if date is NOT in available set | |
| // But with our new logic, all dates in the window should be available | |
| if (!availableSet.has(ymd)) { | |
| disabled.add(d2); | |
| } | |
| } | |
| const prev = daySel.value; | |
| setSelectOptions(daySel, days, prev, disabled); | |
| // If current selection is disabled, find first enabled | |
| if (daySel.options[daySel.selectedIndex] && daySel.options[daySel.selectedIndex].disabled) { | |
| const firstEnabled = Array.from(daySel.options).find(o => !o.disabled); | |
| if (firstEnabled) { | |
| daySel.value = firstEnabled.value; | |
| } | |
| } | |
| } | |
| function recomputeMonthOptions() { | |
| const y = yearSel.value; | |
| if (!y) return; | |
| const months = []; | |
| const disabled = new Set(); | |
| for (let mm = 1; mm <= 12; mm++) { | |
| const m2 = z2(mm); | |
| months.push({ value: m2, label: monthName(m2) }); | |
| // Check if ANY day in this month is available | |
| const hasAny = available.some(d => d.startsWith(`${y}${m2}`)); | |
| if (!hasAny) { | |
| disabled.add(m2); | |
| } | |
| } | |
| const prev = monthSel.value; | |
| setSelectOptions(monthSel, months, prev, disabled); | |
| // If current selection is disabled, find first enabled | |
| if (monthSel.options[monthSel.selectedIndex] && monthSel.options[monthSel.selectedIndex].disabled) { | |
| const firstEnabled = Array.from(monthSel.options).find(o => !o.disabled); | |
| if (firstEnabled) { | |
| monthSel.value = firstEnabled.value; | |
| } | |
| } | |
| } | |
| function ymdUtcPlusDays(days) { | |
| const now = new Date(); | |
| const d = new Date(Date.UTC( | |
| now.getUTCFullYear(), | |
| now.getUTCMonth(), | |
| now.getUTCDate() + days | |
| )); | |
| const y = String(d.getUTCFullYear()); | |
| const m = z2(d.getUTCMonth() + 1); | |
| const dd = z2(d.getUTCDate()); | |
| return `${y}${m}${dd}`; | |
| } | |
| function initSelectorsFromAvailable() { | |
| availableSet = new Set(available); | |
| const years = Array.from(new Set(available.map(d => d.slice(0, 4)))).sort(); | |
| setSelectOptions(yearSel, years.map(y => ({ value: y, label: y })), years[years.length - 1]); | |
| recomputeMonthOptions(); | |
| recomputeDayOptions(); | |
| // ✅ Prefer tomorrow (UTC) if it exists, else latest available | |
| const preferred = ymdUtcPlusDays(1); | |
| const chosen = availableSet.has(preferred) ? preferred : available[available.length - 1]; | |
| if (chosen) { | |
| yearSel.value = chosen.slice(0, 4); | |
| recomputeMonthOptions(); | |
| monthSel.value = chosen.slice(4, 6); | |
| recomputeDayOptions(); | |
| daySel.value = chosen.slice(6, 8); | |
| } | |
| } | |
| let activeDateController = null; | |
| let prefetchController = new AbortController(); | |
| let prefetchRawController = new AbortController(); | |
| function resetPrefetchControllers() { | |
| try { prefetchController.abort(); } catch {} | |
| try { prefetchRawController.abort(); } catch {} | |
| prefetchController = new AbortController(); | |
| prefetchRawController = new AbortController(); | |
| } | |
| function notifyActiveDate(ymd) { | |
| try { | |
| if (activeDateController) activeDateController.abort(); | |
| activeDateController = new AbortController(); | |
| fetch(`${VALUE_BASE}/ui/active_date`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| client_id: CLIENT_ID, | |
| date_yyyymmdd: ymd, | |
| gen: currentGeneration, | |
| }), | |
| signal: activeDateController.signal, | |
| cache: "no-store", | |
| keepalive: true, | |
| }).catch(() => {}); | |
| } catch {} | |
| } | |
| async function loadAvailable() { | |
| const localFallback = generateLocalDateWindow(1, 3); | |
| try { | |
| const r = await fetchWithTimeout( | |
| `${VALUE_BASE}/forecast/available_days?lookback_days=1&lookahead_days=3`, | |
| { cache: "no-store" }, | |
| 3000 | |
| ); | |
| if (!r.ok) { | |
| console.warn(`[loadAvailable] HTTP ${r.status}, using local fallback`); | |
| available = localFallback; | |
| return { ok: true, fallback: true, available_dates: available }; | |
| } | |
| const j = await r.json(); | |
| if (j && j.available_dates && j.available_dates.length > 0) { | |
| available = j.available_dates.slice(); | |
| console.log(`[loadAvailable] Got ${available.length} dates from server`); | |
| return j; | |
| } else { | |
| console.warn("[loadAvailable] Empty response, using local fallback"); | |
| available = localFallback; | |
| return { ok: true, fallback: true, available_dates: available }; | |
| } | |
| } catch (e) { | |
| // Network error, timeout, or parse error - use fallback | |
| console.warn(`[loadAvailable] Error: ${e.message || e}, using local fallback`); | |
| available = localFallback; | |
| return { ok: true, fallback: true, available_dates: available, error: String(e) }; | |
| } | |
| } | |
| /** | |
| * Generate date window locally without any network calls. | |
| * This ensures the app always has valid dates to work with. | |
| */ | |
| function generateLocalDateWindow(lookbackDays, lookaheadDays) { | |
| const dates = []; | |
| const now = new Date(); | |
| for (let offset = -lookbackDays; offset <= lookaheadDays; offset++) { | |
| const d = new Date(Date.UTC( | |
| now.getUTCFullYear(), | |
| now.getUTCMonth(), | |
| now.getUTCDate() + offset | |
| )); | |
| const y = String(d.getUTCFullYear()); | |
| const m = z2(d.getUTCMonth() + 1); | |
| const day = z2(d.getUTCDate()); | |
| dates.push(`${y}${m}${day}`); | |
| } | |
| return dates.sort(); | |
| } | |
| // function showCursorReadout(px, py, text) { | |
| // cursorEl.style.left = px + "px"; | |
| // cursorEl.style.top = py + "px"; | |
| // if (!text) { cursorEl.style.display = "none"; return; } | |
| // cursorEl.textContent = text; | |
| // cursorEl.style.display = "block"; | |
| // } | |
| function showCursorReadout(px, py, text) { | |
| if (!text) { | |
| cursorEl.style.display = "none"; | |
| return; | |
| } | |
| cursorEl.style.transform = `translate(${px + 10}px, ${py + 10}px)`; | |
| cursorEl.textContent = text; | |
| cursorEl.style.display = "block"; | |
| } | |
| // ========== RAW TILE (NPZ) CLIENT-SIDE READOUT ========== | |
| const RAW_TILE_ZMAX = 11; | |
| const RAW_TILE_CACHE_MAX = 300; | |
| let hoverSeq = 0; | |
| let lastHoverKey = ""; | |
| const HOVER_PIXEL_EPS = 2; | |
| let rawTileCache = new Map(); | |
| let rawTileInflight = new Map(); | |
| let rawTileAbortController = new AbortController(); | |
| function rawCacheKey(ymd, hours, z, x, y) { | |
| return `${ymd}|${hours}|${z}|${x}|${y}`; | |
| } | |
| function cancelRawInflightExcept(ymdKeep, hoursKeep) { | |
| const toDelete = []; | |
| for (const [key, v] of rawTileInflight.entries()) { | |
| try { | |
| if (!v) { | |
| toDelete.push(key); | |
| continue; | |
| } | |
| if (v.ymd !== ymdKeep || String(v.hours) !== String(hoursKeep)) { | |
| if (v.controller) v.controller.abort(); | |
| toDelete.push(key); | |
| } | |
| } catch { | |
| toDelete.push(key); | |
| } | |
| } | |
| // Delete outside of iteration | |
| for (const key of toDelete) { | |
| rawTileInflight.delete(key); | |
| } | |
| } | |
| function abortAllRawTileRequests() { | |
| // Abort all in-flight raw tile requests | |
| rawTileAbortController.abort(); | |
| rawTileAbortController = new AbortController(); | |
| // Also abort individual controllers | |
| for (const [key, v] of rawTileInflight.entries()) { | |
| try { | |
| if (v && v.controller) { | |
| v.controller.abort(); | |
| } | |
| } catch {} | |
| } | |
| rawTileInflight.clear(); | |
| } | |
| function rawCacheGet(key) { | |
| if (!rawTileCache.has(key)) return undefined; | |
| const v = rawTileCache.get(key); | |
| rawTileCache.delete(key); | |
| rawTileCache.set(key, v); // Move to end (LRU) | |
| return v; | |
| } | |
| function rawCachePut(key, val) { | |
| if (rawTileCache.has(key)) rawTileCache.delete(key); | |
| rawTileCache.set(key, val); | |
| while (rawTileCache.size > RAW_TILE_CACHE_MAX) { | |
| const first = rawTileCache.keys().next().value; | |
| rawTileCache.delete(first); | |
| } | |
| } | |
| function lonLatToGlobalPixel(lon, lat, z) { | |
| const scale = 256 * (1 << z); | |
| const x = (lon + 180) / 360 * scale; | |
| const s = Math.sin(lat * Math.PI / 180); | |
| const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * scale; | |
| return { x, y }; | |
| } | |
| function parseNpy(u8) { | |
| const magic = String.fromCharCode.apply(null, u8.slice(0, 6)); | |
| if (magic !== "\x93NUMPY") throw new Error("bad npy magic"); | |
| const major = u8[6], minor = u8[7]; | |
| let headerLen, offset; | |
| if (major === 1) { | |
| headerLen = u8[8] | (u8[9] << 8); | |
| offset = 10; | |
| } else { | |
| headerLen = (u8[8] | (u8[9] << 8) | (u8[10] << 16) | (u8[11] << 24)) >>> 0; | |
| offset = 12; | |
| } | |
| const headerTxt = new TextDecoder("latin1").decode(u8.slice(offset, offset + headerLen)); | |
| const dataOffset = offset + headerLen; | |
| const descrM = /'descr'\s*:\s*'([^']+)'/.exec(headerTxt); | |
| const fortranM = /'fortran_order'\s*:\s*(True|False)/.exec(headerTxt); | |
| const shapeM = /'shape'\s*:\s*\(([^)]*)\)/.exec(headerTxt); | |
| if (!descrM || !shapeM) throw new Error("bad npy header"); | |
| const descr = descrM[1]; | |
| const fortran = fortranM ? (fortranM[1] === "True") : false; | |
| if (fortran) throw new Error("fortran npy not supported"); | |
| const shape = shapeM[1].split(",").map(s => s.trim()).filter(Boolean).map(n => parseInt(n, 10)); | |
| const littleEndian = (descr[0] === "<" || descr[0] === "|"); | |
| if (!littleEndian) throw new Error("big-endian npy not supported"); | |
| const typeCode = descr.slice(1); | |
| return { typeCode, shape, dataOffset }; | |
| } | |
| function parseNpz(ab) { | |
| if (!window.fflate) throw new Error("fflate missing"); | |
| if (!ab || ab.byteLength < 4) { | |
| console.warn("[parseNpz] Empty or too-small buffer:", ab?.byteLength); | |
| return null; | |
| } | |
| const bytes = new Uint8Array(ab); | |
| if (bytes[0] !== 0x50 || bytes[1] !== 0x4B || bytes[2] !== 0x03 || bytes[3] !== 0x04) { | |
| if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { | |
| console.warn("[parseNpz] Received PNG instead of NPZ - cache/routing issue"); | |
| return null; | |
| } | |
| if (bytes[0] === 0x3C) { | |
| const text = new TextDecoder().decode(bytes.slice(0, 100)); | |
| console.warn("[parseNpz] Received HTML instead of NPZ:", text.slice(0, 50)); | |
| return null; | |
| } | |
| if (bytes[0] === 0x1F && bytes[1] === 0x8B) { | |
| console.warn("[parseNpz] Received gzipped data - attempting to decompress"); | |
| try { | |
| const decompressed = window.fflate.gunzipSync(bytes); | |
| return parseNpz(decompressed.buffer); | |
| } catch (e) { | |
| console.warn("[parseNpz] Gzip decompression failed:", e); | |
| return null; | |
| } | |
| } | |
| console.warn("[parseNpz] Invalid NPZ magic bytes:", | |
| bytes[0].toString(16), bytes[1].toString(16), bytes[2].toString(16), bytes[3].toString(16)); | |
| return null; | |
| } | |
| let files; | |
| try { | |
| files = window.fflate.unzipSync(bytes); | |
| } catch (e) { | |
| console.warn("[parseNpz] fflate.unzipSync failed:", e.message); | |
| return null; | |
| } | |
| const fileNames = Object.keys(files); | |
| const qn = fileNames.find(n => /(^|\/)q\.npy$/.test(n)); | |
| const mn = fileNames.find(n => /(^|\/)m\.npy$/.test(n)); | |
| const sn = fileNames.find(n => /(^|\/)s\.npy$/.test(n)); | |
| if (!qn || !mn) { | |
| console.warn("[parseNpz] NPZ missing q.npy or m.npy, found:", fileNames); | |
| return null; | |
| } | |
| const qbuf = files[qn]; | |
| const mbuf = files[mn]; | |
| let qmeta, mmeta; | |
| try { | |
| qmeta = parseNpy(qbuf); | |
| mmeta = parseNpy(mbuf); | |
| } catch (e) { | |
| console.warn("[parseNpz] Failed to parse npy headers:", e.message); | |
| return null; | |
| } | |
| const qdata = qbuf.slice(qmeta.dataOffset); | |
| const mdata = mbuf.slice(mmeta.dataOffset); | |
| if (qmeta.typeCode !== "u2") { | |
| console.warn("[parseNpz] q.npy expected uint16, got:", qmeta.typeCode); | |
| return null; | |
| } | |
| if (mmeta.typeCode !== "u1") { | |
| console.warn("[parseNpz] m.npy expected uint8, got:", mmeta.typeCode); | |
| return null; | |
| } | |
| if (qmeta.shape.length !== 2) { | |
| console.warn("[parseNpz] q.npy not 2D:", qmeta.shape); | |
| return null; | |
| } | |
| const q = new Uint16Array(qdata.buffer, qdata.byteOffset, qdata.byteLength / 2); | |
| const m = new Uint8Array(mdata.buffer, mdata.byteOffset, mdata.byteLength); | |
| let scale = 10; | |
| if (sn && files[sn]) { | |
| try { | |
| const sbuf = files[sn]; | |
| const smeta = parseNpy(sbuf); | |
| const sdata = sbuf.slice(smeta.dataOffset); | |
| const sarr = new Uint8Array(sdata.buffer, sdata.byteOffset, sdata.byteLength); | |
| if (sarr.length) scale = sarr[0]; | |
| } catch (e) { | |
| // Use default scale | |
| } | |
| } | |
| return { q, m, shape: qmeta.shape, scale }; | |
| } | |
| async function loadRawTile(ymd, hours, z, x, y, gen) { | |
| const key = rawCacheKey(ymd, hours, z, x, y); | |
| // Check cache first | |
| const hit = rawCacheGet(key); | |
| if (hit !== undefined) { | |
| // If it's a valid tile, return it | |
| // If it's null (no data), return null | |
| // BUT if it's a cached error marker, we might retry | |
| if (hit === null) { | |
| // Check if this was a "permanent" no-data vs "temporary" error | |
| // For now, return null but don't block retries after some time | |
| return null; | |
| } | |
| return hit; | |
| } | |
| // Check if already in-flight | |
| const infl = rawTileInflight.get(key); | |
| if (infl && infl.promise) { | |
| // If it's for a compatible generation, wait for it | |
| if (gen === undefined || infl.gen === gen || infl.gen === undefined) { | |
| return infl.promise; | |
| } | |
| // Otherwise, let it continue but start our own request | |
| } | |
| // Generation check before starting | |
| if (gen !== undefined && !isCurrentGeneration(gen)) { | |
| return null; | |
| } | |
| const controller = new AbortController(); | |
| // Link to global abort controller | |
| const globalSignal = rawTileAbortController.signal; | |
| if (globalSignal.aborted) { | |
| return null; | |
| } | |
| const onGlobalAbort = () => controller.abort(); | |
| globalSignal.addEventListener('abort', onGlobalAbort, { once: true }); | |
| const p = (async () => { | |
| try { | |
| // Check generation again before fetch | |
| if (gen !== undefined && !isCurrentGeneration(gen)) { | |
| return null; | |
| } | |
| const url = rawTileUrl(z, x, y, ymd, hours); | |
| // Add cache-busting for debugging if needed | |
| // const url = rawTileUrl(z, x, y, ymd, hours) + `&_t=${Date.now()}`; | |
| const r = await fetch(url, { | |
| cache: "default", // Changed from "force-cache" to allow fresh fetches | |
| signal: controller.signal | |
| }); | |
| // Check generation after fetch | |
| if (gen !== undefined && !isCurrentGeneration(gen)) { | |
| return null; | |
| } | |
| // Handle HTTP errors | |
| if (!r.ok) { | |
| if (r.status === 404) { | |
| // 404 = tile doesn't exist (maybe outside bounds) | |
| // Cache this as "no data" | |
| rawCachePut(key, null); | |
| return null; | |
| } else if (r.status === 503 || r.status === 502 || r.status === 504) { | |
| // Server busy/unavailable - DON'T cache, allow retry | |
| console.warn(`[loadRawTile] Server returned ${r.status} for ${ymd}/${hours}/${z}/${x}/${y} - will retry`); | |
| return null; // Don't cache this | |
| } else { | |
| // Other error | |
| console.warn(`[loadRawTile] HTTP ${r.status} for ${ymd}/${hours}/${z}/${x}/${y}`); | |
| return null; // Don't cache unknown errors | |
| } | |
| } | |
| // Check content-type | |
| const ct = r.headers.get("content-type") || ""; | |
| if (ct.includes("text/html")) { | |
| // Got an error page instead of data | |
| console.warn("[loadRawTile] Got HTML instead of NPZ - server error?"); | |
| return null; // Don't cache | |
| } | |
| if (ct.includes("image/png")) { | |
| // Got PNG instead of NPZ - wrong endpoint? | |
| console.warn("[loadRawTile] Got PNG instead of NPZ - wrong URL?"); | |
| rawCachePut(key, null); | |
| return null; | |
| } | |
| const ab = await r.arrayBuffer(); | |
| // Check generation after getting data | |
| if (gen !== undefined && !isCurrentGeneration(gen)) { | |
| return null; | |
| } | |
| if (!ab || ab.byteLength < 16) { | |
| console.warn(`[loadRawTile] Empty response for ${ymd}/${hours}/${z}/${x}/${y}`); | |
| return null; // Don't cache empty responses | |
| } | |
| const tile = parseNpz(ab); | |
| if (tile) { | |
| // Successfully parsed - cache it | |
| rawCachePut(key, tile); | |
| return tile; | |
| } else { | |
| // Parse failed - might be corrupt or wrong format | |
| console.warn(`[loadRawTile] Failed to parse NPZ for ${ymd}/${hours}/${z}/${x}/${y}`); | |
| return null; // Don't cache parse failures | |
| } | |
| } catch (e) { | |
| if (e.name === 'AbortError') { | |
| // Request was cancelled - don't log or cache | |
| return null; | |
| } | |
| console.warn(`[loadRawTile] Error for ${ymd}/${hours}/${z}/${x}/${y}:`, e.message || e); | |
| return null; // Don't cache errors | |
| } finally { | |
| globalSignal.removeEventListener('abort', onGlobalAbort); | |
| } | |
| })().finally(() => { | |
| rawTileInflight.delete(key); | |
| }); | |
| rawTileInflight.set(key, { promise: p, controller, ymd, hours, gen }); | |
| return p; | |
| } | |
| const prefetchRawPush = makeQueue(MAX_CONCURRENT_RAW_FETCHES); | |
| const rawPrefetched = new Set(); | |
| function sampleRawTile(tile, px, py) { | |
| if (!tile || !tile.q || !tile.m || !tile.shape) return null; | |
| const h = tile.shape[0], w = tile.shape[1]; | |
| if (px < 0 || py < 0 || px >= w || py >= h) return null; | |
| const idx = py * w + px; | |
| if (tile.m[idx] === 0) return null; | |
| const tenths = tile.q[idx]; | |
| const inches = tenths / (tile.scale || 10); | |
| if (!isFinite(inches)) return null; | |
| return inches; | |
| } | |
| function prefetchViewportRawTilesImpl() { | |
| const z = map.getZoom(); | |
| if (z < PREFETCH_ZMIN) return; | |
| if (z > RAW_TILE_ZMAX) return; | |
| const ymd = selectedDateYMD(); | |
| if (!ymd) return; | |
| const hours = getHours(); | |
| const gen = currentGeneration; // Capture generation | |
| const effectiveZ = Math.min(RAW_TILE_ZMAX, Math.max(0, Math.floor(z))); | |
| const b = map.getBounds(); | |
| const r = tileRangeForBounds(b, effectiveZ); | |
| const MAX_PREFETCH_BATCH = 16; | |
| let count = 0; | |
| for (let x = r.xmin; x <= r.xmax && count < MAX_PREFETCH_BATCH; x++) { | |
| for (let y = r.ymin; y <= r.ymax && count < MAX_PREFETCH_BATCH; y++) { | |
| const u = rawTileUrl(effectiveZ, x, y, ymd, hours); | |
| if (rawPrefetched.has(u)) continue; | |
| rawPrefetched.add(u); | |
| count++; | |
| prefetchRawPush((signal) => { | |
| if (!isCurrentGeneration(gen)) return Promise.resolve(); | |
| return loadRawTile(ymd, hours, effectiveZ, x, y, gen); | |
| }, gen); | |
| } | |
| } | |
| } | |
| const prefetchViewportRawTiles = throttle(prefetchViewportRawTilesImpl, PREFETCH_THROTTLE_MS); | |
| const prefetchRawDebounced = debounce(prefetchViewportRawTiles, 300); | |
| async function updateCursorReadout(lngLat, point, seq) { | |
| const gen = currentGeneration; // Capture generation at start | |
| if (seq !== hoverSeq) return; | |
| if (!isCurrentGeneration(gen)) return; | |
| if (map.getZoom() < CURSOR_ZMIN) { | |
| showCursorReadout(point.x, point.y, ""); | |
| return; | |
| } | |
| if (!tracksReady()) { | |
| showCursorReadout(point.x, point.y, ""); | |
| return; | |
| } | |
| if (!nearTrack(point, lngLat)) { | |
| showCursorReadout(point.x, point.y, ""); | |
| return; | |
| } | |
| const ymd = selectedDateYMD(); | |
| if (!ymd) { | |
| showCursorReadout(point.x, point.y, ""); | |
| return; | |
| } | |
| const hours = getHours(); | |
| const z = Math.min(11, Math.max(0, Math.floor(map.getZoom()))); | |
| const max = (1 << z) - 1; | |
| const tx = clamp(lon2tile(lngLat.lng, z), 0, max); | |
| const ty = clamp(lat2tile(lngLat.lat, z), 0, max); | |
| const gp = lonLatToGlobalPixel(lngLat.lng, lngLat.lat, z); | |
| const px = Math.floor(gp.x - (tx * 256)); | |
| const py = Math.floor(gp.y - (ty * 256)); | |
| // Pass generation to loadRawTile | |
| const tile = await loadRawTile(ymd, hours, z, tx, ty, gen); | |
| // Check both seq AND generation after await | |
| if (seq !== hoverSeq) return; | |
| if (!isCurrentGeneration(gen)) return; | |
| const inches = sampleRawTile(tile, px, py); | |
| if (inches != null && inches > TRACE_INCHES) { | |
| showCursorReadout(point.x, point.y, inches.toFixed(2) + " in"); | |
| } else { | |
| showCursorReadout(point.x, point.y, ""); | |
| } | |
| } | |
| function onSelectionChange() { | |
| const gen = currentGeneration; | |
| rawPrefetched.clear(); | |
| // Only prefetch if still on the same generation | |
| if (isCurrentGeneration(gen)) { | |
| prefetchViewportRawTiles(); | |
| } | |
| } | |
| function shouldReadout() { | |
| const z = map.getZoom(); | |
| const ok = z >= MIN_ZOOM_FOR_READOUT; | |
| if (!ok && readoutsEnabled) { | |
| readoutsEnabled = false; | |
| lastKey = ""; | |
| showCursorReadout(0, 0, ""); | |
| } else if (ok && !readoutsEnabled) { | |
| readoutsEnabled = true; | |
| } | |
| return ok; | |
| } | |
| const throttledCursorUpdate = throttle((lngLat, point, seq) => { | |
| updateCursorReadout(lngLat, point, seq); | |
| }, READOUT_THROTTLE_MS); | |
| // map.on("mousemove", (e) => { | |
| // if (cursorEl.style.display !== "none") { | |
| // cursorEl.style.left = e.point.x + "px"; | |
| // cursorEl.style.top = e.point.y + "px"; | |
| // } | |
| // if (!shouldReadout()) return; | |
| // if (!tracksReady()) return; | |
| // const px = Math.round(e.point.x); | |
| // const py = Math.round(e.point.y); | |
| // const ymd = selectedDateYMD(); | |
| // const hours = getHours(); | |
| // if (!ymd) return; | |
| // const key = `${ymd}|${hours}|${px}|${py}`; | |
| // if (lastHoverKey) { | |
| // const parts = lastHoverKey.split("|"); | |
| // const opx = parseInt(parts[2], 10); | |
| // const opy = parseInt(parts[3], 10); | |
| // if (Math.abs(px - opx) <= HOVER_PIXEL_EPS && Math.abs(py - opy) <= HOVER_PIXEL_EPS) { | |
| // return; | |
| // } | |
| // } | |
| // lastHoverKey = key; | |
| // const mySeq = ++hoverSeq; | |
| // // Use throttled version instead of direct call | |
| // throttledCursorUpdate(e.lngLat, e.point, mySeq); | |
| // }); | |
| map.on("mousemove", (e) => { | |
| if (cursorEl.style.display !== "none") { | |
| cursorEl.style.transform = `translate(${e.point.x + 10}px, ${e.point.y + 10}px)`; | |
| } | |
| if (!shouldReadout()) return; | |
| if (!tracksReady()) return; | |
| const px = Math.round(e.point.x); | |
| const py = Math.round(e.point.y); | |
| const ymd = selectedDateYMD(); | |
| const hours = getHours(); | |
| if (!ymd) return; | |
| const key = `${ymd}|${hours}|${px}|${py}`; | |
| if (lastHoverKey) { | |
| const parts = lastHoverKey.split("|"); | |
| const opx = parseInt(parts[2], 10); | |
| const opy = parseInt(parts[3], 10); | |
| if (Math.abs(px - opx) <= HOVER_PIXEL_EPS && Math.abs(py - opy) <= HOVER_PIXEL_EPS) { | |
| return; | |
| } | |
| } | |
| lastHoverKey = key; | |
| const mySeq = ++hoverSeq; | |
| throttledCursorUpdate(e.lngLat, e.point, mySeq); | |
| }); | |
| map.on("mouseout", () => { | |
| showCursorReadout(0, 0, ""); | |
| }); | |
| map.on("moveend", () => { | |
| prefetchRawDebounced(); | |
| }); | |
| map.on("zoomend", () => { | |
| shouldReadout(); | |
| if (map.getZoom() < CURSOR_ZMIN) { lastKey = ""; showCursorReadout(0, 0, ""); } | |
| prefetchDebounced(); | |
| prefetchRawDebounced(); | |
| }); | |
| map.on("error", (e) => { | |
| const err = e.error; | |
| const msg = err ? err.message : String(e); | |
| // Suppress common tile errors | |
| if (msg.includes("404") || | |
| msg.includes("timeout") || | |
| msg.includes("TIMED_OUT") || | |
| msg.includes("Unimplemented type")) { | |
| console.warn("Tile error (suppressed):", msg); | |
| return; | |
| } | |
| showErr("Map error:\n" + msg); | |
| }); | |
| // === MAIN INITIALIZATION - PARALLELIZED === | |
| map.on("load", async () => { | |
| try { | |
| setProgress(40, 'Loading map style...'); | |
| renderLegend(24); | |
| // Call directly — load has already fired, so once("load") would never trigger | |
| applyBasemapMutingOnce(); | |
| // === 3D terrain === | |
| if (!map.getSource("mapbox-dem")) { | |
| map.addSource("mapbox-dem", { | |
| type: "raster-dem", | |
| url: "mapbox://mapbox.mapbox-terrain-dem-v1", | |
| tileSize: 512, | |
| maxzoom: 14 | |
| }); | |
| } | |
| if (!map.getLayer("terrain-hillshade")) { | |
| map.addLayer({ | |
| id: "terrain-hillshade", | |
| type: "hillshade", | |
| source: "mapbox-dem", | |
| paint: { | |
| "hillshade-exaggeration": 0.55, | |
| "hillshade-shadow-color": "#666666", | |
| "hillshade-highlight-color": "#e6e6e6", | |
| "hillshade-accent-color": "#8a8a8a" | |
| } | |
| }); | |
| } else { | |
| map.setPaintProperty("terrain-hillshade", "hillshade-exaggeration", 0.55); | |
| map.setPaintProperty("terrain-hillshade", "hillshade-shadow-color", "#666666"); | |
| map.setPaintProperty("terrain-hillshade", "hillshade-highlight-color", "#e6e6e6"); | |
| map.setPaintProperty("terrain-hillshade", "hillshade-accent-color", "#8a8a8a"); | |
| } | |
| // Safely mute/adjust satellite layer if it exists in the loaded style: | |
| if (map.getLayer("satellite") || map.getLayer("satellite-raster")) { | |
| // Try common ids used by mapbox styles | |
| // const satId = map.getLayer("satellite") ? "satellite" | |
| // : map.getLayer("satellite-layer") ? "satellite-layer" | |
| // : map.getLayer("satellite-raster") ? "satellite-raster" | |
| // : null; | |
| // if (satId) map.setPaintProperty(satId, "raster-opacity", 0.12); | |
| const satId = map.getLayer("satellite") ? "satellite" | |
| : map.getLayer("satellite-layer") ? "satellite-layer" | |
| : map.getLayer("satellite-raster") ? "satellite-raster" | |
| : null; | |
| if (satId) { | |
| map.setPaintProperty(satId, "raster-opacity", 0.55); | |
| map.setPaintProperty(satId, "raster-saturation", -0.20); | |
| map.setPaintProperty(satId, "raster-contrast", 0.05); | |
| map.setPaintProperty(satId, "raster-brightness-min", 0.25); | |
| map.setPaintProperty(satId, "raster-brightness-max", 0.95); | |
| } | |
| } else { | |
| // Many styles call the layer "satellite" or "satellite-layer"; if not found we can set it on styledata events | |
| map.once("styledata", () => { | |
| if (map.getLayer("satellite")) map.setPaintProperty("satellite", "raster-opacity", 0.4); | |
| }); | |
| } | |
| // Start BOTH fetches in parallel - don't await tracks | |
| const availPromise = loadAvailable(); | |
| setProgress(50, 'Loading track network...'); | |
| // Add tracks NON-BLOCKING - they'll render when ready | |
| addCnTracksVectorTiles(); | |
| setProgress(60, 'Fetching available dates...'); | |
| await availPromise; | |
| if (!available.length) { | |
| showErr("No available days returned from /forecast/available_days"); | |
| hideInitialLoading(); | |
| return; | |
| } | |
| setProgress(80, 'Loading snowmelt tiles...'); | |
| initSelectorsFromAvailable(); | |
| setTilesForSelection(); | |
| prefetchViewportTiles(); | |
| setOpacity(parseFloat(OPACITY.value)); | |
| shouldReadout(); | |
| setProgress(100, 'Ready!'); | |
| // Hide loading screen once map is interactive | |
| setTimeout(() => hideInitialLoading(), 300); | |
| // Delay raw tile prefetch so visual tiles load first | |
| setTimeout(() => onSelectionChange(), 3000); | |
| } catch (e) { | |
| showErr("Init error:\n" + String(e)); | |
| hideInitialLoading(); | |
| } | |
| }); | |
| // const tileSpinner = document.getElementById("tileSpinner"); | |
| // let tilesLoading = 0; | |
| // let tilesLoaded = 0; | |
| // let spinnerDone = false; | |
| // map.on("dataloading", (e) => { | |
| // if (spinnerDone) return; | |
| // if (e.dataType === "source" && e.sourceId && e.sourceId.startsWith(SRC_ID)) { | |
| // tilesLoading++; | |
| // if (tileSpinner) tileSpinner.classList.add("active"); | |
| // } | |
| // }); | |
| // map.on("data", (e) => { | |
| // if (e.dataType === "source" && e.sourceId && e.sourceId.startsWith(SRC_ID)) { | |
| // tilesLoading = Math.max(0, tilesLoading - 1); | |
| // tilesLoaded++; | |
| // if ((tilesLoaded >= 10 || tilesLoading <= 0) && tileSpinner) { | |
| // tileSpinner.classList.remove("active"); | |
| // spinnerDone = true; | |
| // } | |
| // } | |
| // }); | |
| // Heartbeat: any map interaction pauses background server jobs | |
| let lastHeartbeat = 0; | |
| function sendHeartbeat() { | |
| const now = Date.now(); | |
| if (now - lastHeartbeat < 10000) return; // Max once per 10 seconds | |
| lastHeartbeat = now; | |
| const payload = JSON.stringify({ | |
| client_id: CLIENT_ID, | |
| date_yyyymmdd: selectedDateYMD() || "00000000", | |
| gen: currentGeneration, | |
| }); | |
| try { | |
| if (navigator.sendBeacon) { | |
| navigator.sendBeacon(`${VALUE_BASE}/ui/active_date`, new Blob([payload], { type: "application/json" })); | |
| } | |
| } catch {} | |
| } | |
| map.on("movestart", sendHeartbeat); | |
| map.on("zoomstart", sendHeartbeat); | |
| map.on("mousemove", sendHeartbeat); | |
| map.on("click", sendHeartbeat); | |
| document.addEventListener("visibilitychange", () => { | |
| if (!document.hidden) sendHeartbeat(); | |
| }); | |
| // Immediate update function (no debounce for user-initiated changes) | |
| function immediateSelectionUpdate() { | |
| const ymd = selectedDateYMD(); | |
| const hours = getHours(); | |
| if (!ymd) return; | |
| // This does all the abort/cancel work | |
| setTilesForSelection(); | |
| } | |
| // Debounced version for programmatic/rapid changes | |
| const debouncedTileUpdate = debounce(immediateSelectionUpdate, 50); // Reduced from 100ms | |
| yearSel.addEventListener("change", () => { | |
| recomputeMonthOptions(); | |
| recomputeDayOptions(); | |
| immediateSelectionUpdate(); // Immediate, not debounced | |
| }); | |
| monthSel.addEventListener("change", () => { | |
| recomputeDayOptions(); | |
| immediateSelectionUpdate(); // Immediate, not debounced | |
| }); | |
| daySel.addEventListener("change", () => { | |
| immediateSelectionUpdate(); // Immediate, not debounced | |
| }); | |
| document.querySelectorAll('input[name="hours"]').forEach(el => | |
| el.addEventListener("change", () => { | |
| immediateSelectionUpdate(); // Immediate, not debounced | |
| }) | |
| ); | |
| OPACITY.addEventListener("input", () => setOpacity(parseFloat(OPACITY.value))); | |
| // Mark performance timing | |
| performance.mark('fullLoad'); | |
| // Debug helper - call from browser console: window.debugReadout() | |
| // Run this in console to clear the poisoned cache | |
| window.debugReadout = function() { | |
| const ymd = selectedDateYMD(); | |
| const hours = getHours(); | |
| const z = Math.floor(map.getZoom()); | |
| const center = map.getCenter(); | |
| console.log("=== READOUT DEBUG ==="); | |
| console.log("Date:", ymd); | |
| console.log("Hours:", hours); | |
| console.log("Zoom:", z, "(need >= 8 for readout)"); | |
| console.log("Generation:", currentGeneration); | |
| console.log("hoverSeq:", hoverSeq); | |
| console.log("readoutsEnabled:", readoutsEnabled); | |
| console.log("tracksReady:", tracksReady()); | |
| console.log("Raw tile cache size:", rawTileCache.size); | |
| console.log("Raw tile inflight:", rawTileInflight.size); | |
| // Try to load a tile for center of map | |
| const tx = lon2tile(center.lng, z); | |
| const ty = lat2tile(center.lat, z); | |
| const key = rawCacheKey(ymd, hours, z, tx, ty); | |
| console.log("Center tile key:", key); | |
| console.log("Cached?:", rawTileCache.has(key)); | |
| console.log("Cached value:", rawTileCache.get(key)); | |
| // Try fetching | |
| console.log("Attempting fetch..."); | |
| const url = rawTileUrl(z, tx, ty, ymd, hours); | |
| console.log("URL:", url); | |
| // Check what's actually cached for the center tile | |
| console.log("Key:", key); | |
| console.log("Raw cache has key:", rawTileCache.has(key)); | |
| console.log("Cached value:", rawTileCache.get(key)); | |
| fetch(url) | |
| .then(r => { | |
| console.log("Response status:", r.status); | |
| console.log("Content-Type:", r.headers.get("content-type")); | |
| return r.arrayBuffer(); | |
| }) | |
| .then(ab => { | |
| console.log("Response size:", ab.byteLength, "bytes"); | |
| if (ab.byteLength > 0) { | |
| const bytes = new Uint8Array(ab); | |
| console.log("First 4 bytes:", bytes[0].toString(16), bytes[1].toString(16), bytes[2].toString(16), bytes[3].toString(16)); | |
| if (bytes[0] === 0x50 && bytes[1] === 0x4B) { | |
| console.log("✓ Valid ZIP/NPZ header"); | |
| } else { | |
| console.log("✗ Invalid header - not NPZ"); | |
| } | |
| } | |
| }) | |
| .catch(e => console.error("Fetch error:", e)); | |
| return "Check console for results"; | |
| }; | |
| }); | |
| </script> | |
| </body> | |
| </html> |