|
| 1 | +<!doctype html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="utf-8" /> |
| 5 | + <title>PRISM Interactive Map (streamed)</title> |
| 6 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 7 | + <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> |
| 8 | + <style> |
| 9 | + :root { --shadow: 0 1px 4px rgba(0,0,0,.1); } |
| 10 | + body {margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;} |
| 11 | + .toolbar {padding:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap; box-shadow:var(--shadow);} |
| 12 | + #map {width:100vw; height: calc(100vh - 62px);} |
| 13 | + label {font-size:14px; color:#333;} |
| 14 | + select, input[type="date"], button {padding:4px 6px; font-size:14px;} |
| 15 | + .msg {margin-left:auto; font-size:13px; color:#666} |
| 16 | + </style> |
| 17 | +</head> |
| 18 | +<body> |
| 19 | + <div class="toolbar"> |
| 20 | + <label>Variable |
| 21 | + <select id="var"> |
| 22 | + <option value="tmax">tmax (°C×10)</option> |
| 23 | + <option value="tmin">tmin (°C×10)</option> |
| 24 | + <option value="tmean">tmean (°C×10)</option> |
| 25 | + <option value="ppt">ppt (mm)</option> |
| 26 | + <option value="tdmean">tdmean (°C×10)</option> |
| 27 | + <option value="vpdmin">vpdmin (hPa)</option> |
| 28 | + <option value="vpdmax">vpdmax (hPa)</option> |
| 29 | + </select> |
| 30 | + </label> |
| 31 | + <label>Frequency |
| 32 | + <select id="freq"> |
| 33 | + <option value="daily" selected>daily</option> |
| 34 | + <option value="monthly">monthly</option> |
| 35 | + <option value="annual">annual</option> |
| 36 | + </select> |
| 37 | + </label> |
| 38 | + <label>Date |
| 39 | + <!-- Daily uses YYYY-MM-DD; monthly choose any day in target month --> |
| 40 | + <input id="date" type="date" value="2025-07-15"> |
| 41 | + </label> |
| 42 | + <label>Resolution |
| 43 | + <select id="res"> |
| 44 | + <option value="800m" selected>800 m</option> |
| 45 | + <option value="4km">4 km</option> |
| 46 | + <option value="400m">400 m</option> |
| 47 | + </select> |
| 48 | + </label> |
| 49 | + <label>Dataset |
| 50 | + <select id="dataset" title="LT only valid for monthly 800 m"> |
| 51 | + <option value="an" selected>an (all networks)</option> |
| 52 | + <option value="lt">lt (monthly 800 m only)</option> |
| 53 | + </select> |
| 54 | + </label> |
| 55 | + <button id="load" title="Fetch zipped COG → unzip → draw">Load</button> |
| 56 | + <div class="msg" id="msg">Tip: temperatures are °C × 10</div> |
| 57 | + </div> |
| 58 | + |
| 59 | + <div id="map"></div> |
| 60 | + |
| 61 | + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> |
| 62 | + <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> |
| 63 | + <script src="https://unpkg.com/geotiff@2.0.7/dist-browser/geotiff.min.js"></script> |
| 64 | + <script src="https://unpkg.com/georaster/dist/georaster.browser.min.js"></script> |
| 65 | + <script src="https://unpkg.com/georaster-layer-for-leaflet/dist/georaster-layer-for-leaflet.min.js"></script> |
| 66 | + |
| 67 | + <script> |
| 68 | + // Basemap |
| 69 | + const map = L.map('map', { preferCanvas: true }).setView([39, -98], 5); |
| 70 | + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap' }).addTo(map); |
| 71 | + |
| 72 | + let currentLayer = null; |
| 73 | + const RES_IN = { "4km":"25m", "800m":"30s", "400m":"15s" }; // filename tag INSIDE ZIP |
| 74 | + const RES_WS = { "4km":"4km", "800m":"800m", "400m":"400m" }; // web-service param |
| 75 | + |
| 76 | + function ymd(d){ return d.replaceAll('-',''); } |
| 77 | + function ym(d){ return d.slice(0,7).replace('-',''); } |
| 78 | + |
| 79 | + function buildUrlAndInnerName({variable, date, freq, resolution, dataset}) { |
| 80 | + const region = "us"; |
| 81 | + const datecode = (freq==="daily") ? ymd(date) |
| 82 | + : (freq==="monthly") ? ym(date) |
| 83 | + : date.slice(0,4); |
| 84 | + if (dataset==="lt" && !(freq==="monthly" && resolution==="800m")) { |
| 85 | + throw new Error("dataset='lt' is only valid for monthly 800 m."); |
| 86 | + } |
| 87 | + const tail = (dataset==="lt") ? "/lt" : ""; |
| 88 | + const base = `https://services.nacse.org/prism/data/get/${region}/${RES_WS[resolution]}/${variable}/${datecode}${tail}`; |
| 89 | + const inner = `prism_${variable}_${region}_${RES_IN[resolution]}_${datecode}.tif`; |
| 90 | + return { zipUrl: base, innerName: inner }; |
| 91 | + } |
| 92 | + |
| 93 | + function rampRGBA(min, max) { |
| 94 | + return (v) => { |
| 95 | + if (v==null || isNaN(v)) return [0,0,0,0]; |
| 96 | + const t = Math.min(1, Math.max(0, (v - min) / (max - min || 1))); |
| 97 | + const r = Math.round(255 * t); |
| 98 | + const b = Math.round(255 * (1 - t)); |
| 99 | + return [r, 0, b, 190]; |
| 100 | + }; |
| 101 | + } |
| 102 | + |
| 103 | + async function loadRaster() { |
| 104 | + const variable = document.getElementById('var').value; |
| 105 | + const freq = document.getElementById('freq').value; |
| 106 | + const date = document.getElementById('date').value; |
| 107 | + const resolution = document.getElementById('res').value; |
| 108 | + const dataset = document.getElementById('dataset').value; |
| 109 | + const msg = document.getElementById('msg'); |
| 110 | + |
| 111 | + try { |
| 112 | + const { zipUrl, innerName } = buildUrlAndInnerName({variable, date, freq, resolution, dataset}); |
| 113 | + msg.textContent = "Fetching grid package…"; |
| 114 | + const resp = await fetch(zipUrl); // may fail if CORS is denied by server |
| 115 | + if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${zipUrl}`); |
| 116 | + const buf = await resp.arrayBuffer(); |
| 117 | + |
| 118 | + msg.textContent = "Unzipping…"; |
| 119 | + const zip = await JSZip.loadAsync(buf); |
| 120 | + const entry = zip.file(innerName); |
| 121 | + if (!entry) throw new Error(`Inner file not found: ${innerName}`); |
| 122 | + |
| 123 | + msg.textContent = "Parsing GeoTIFF…"; |
| 124 | + const tiffArrayBuf = await entry.async("arraybuffer"); |
| 125 | + const georaster = await parseGeoraster(tiffArrayBuf); |
| 126 | + |
| 127 | + const isTemp = ["tmin","tmax","tmean"].includes(variable); |
| 128 | + const min = (georaster.mins?.[0] ?? 0) / (isTemp ? 10 : 1); |
| 129 | + const max = (georaster.maxs?.[0] ?? 1) / (isTemp ? 10 : 1); |
| 130 | + const colorFnRaw = rampRGBA(min, max); |
| 131 | + const colorFn = (val) => colorFnRaw(isTemp && typeof val==="number" ? val/10.0 : val); |
| 132 | + |
| 133 | + if (currentLayer) { map.removeLayer(currentLayer); currentLayer = null; } |
| 134 | + currentLayer = new GeoRasterLayer({ |
| 135 | + georaster, |
| 136 | + pixelValuesToColorFn: colorFn, |
| 137 | + resolution: 256, |
| 138 | + opacity: 0.8 |
| 139 | + }).addTo(map); |
| 140 | + |
| 141 | + map.fitBounds(currentLayer.getBounds()); |
| 142 | + msg.textContent = `Drawn: ${variable} ${freq} ${date} (${resolution})`; |
| 143 | + } catch (err) { |
| 144 | + console.error(err); |
| 145 | + msg.textContent = "Could not load (likely CORS or bad parameters). See console for details."; |
| 146 | + if (currentLayer) { map.removeLayer(currentLayer); currentLayer = null; } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + document.getElementById('load').addEventListener('click', loadRaster); |
| 151 | + loadRaster(); // initial draw |
| 152 | + </script> |
| 153 | +</body> |
| 154 | +</html> |
0 commit comments