Skip to content

Commit 25b70d7

Browse files
authored
Merge pull request #11 from CU-ESIIL/codex/add-interactive-prism-map-and-demo
Add PRISM interactive map and tiling workflow
2 parents 77e4d7b + 599a1b2 commit 25b70d7

4 files changed

Lines changed: 238 additions & 0 deletions

File tree

.github/workflows/prism_tiles.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Build PRISM tiles
2+
on:
3+
workflow_dispatch:
4+
inputs:
5+
variable: { description: "ppt|tmax|tmin|tmean|tdmean|vpdmin|vpdmax", required: true, default: "tmax" }
6+
date: { description: "YYYY-MM-DD (daily) | YYYY-MM (monthly) | YYYY (annual)", required: true, default: "2025-07-15" }
7+
freq: { description: "daily|monthly|annual", required: true, default: "daily" }
8+
resolution: { description: "800m|4km|400m", required: true, default: "800m" }
9+
dataset: { description: "an or lt (lt only for monthly 800m)", required: true, default: "an" }
10+
tag: { description: "tile folder name", required: true, default: "tmax_2025-07-15_800m" }
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- name: Install GDAL
17+
run: sudo apt-get update && sudo apt-get install -y gdal-bin python3-gdal curl unzip
18+
- name: Make tiles
19+
shell: bash
20+
run: |
21+
set -euo pipefail
22+
VAR="${{ inputs.variable }}"
23+
DATE="${{ inputs.date }}"
24+
FREQ="${{ inputs.freq }}"
25+
RES="${{ inputs.resolution }}"
26+
DATASET="${{ inputs.dataset }}"
27+
TAG="${{ inputs.tag }}"
28+
case "$RES" in
29+
800m) RESIN="30s";;
30+
4km) RESIN="25m";;
31+
400m) RESIN="15s";;
32+
*) echo "bad resolution"; exit 1;;
33+
esac
34+
# Build datecode
35+
DATECODE="$DATE"
36+
if [ "$FREQ" = "daily" ]; then DATECODE="${DATE//-/}"; fi
37+
if [ "$FREQ" = "monthly" ]; then DATECODE="${DATE:0:7}"; DATECODE="${DATECODE/-/}"; fi
38+
if [ "$FREQ" = "annual" ]; then DATECODE="${DATE:0:4}"; fi
39+
# LT only for monthly 800m
40+
TAIL=""
41+
if [ "$DATASET" = "lt" ]; then
42+
if [ "$FREQ" != "monthly" ] || [ "$RES" != "800m" ]; then echo "lt only for monthly 800m"; exit 1; fi
43+
TAIL="/lt"
44+
fi
45+
BASE="https://services.nacse.org/prism/data/get/us/$RES/$VAR/$DATECODE$TAIL"
46+
INNER="prism_${VAR}_us_${RESIN}_${DATECODE}.tif"
47+
mkdir -p /tmp/prism && cd /tmp/prism
48+
echo "Fetching $BASE"
49+
curl -sS -L -o prism.zip "$BASE"
50+
unzip -o prism.zip "$INNER"
51+
mkdir -p "$GITHUB_WORKSPACE/docs/assets/prism_tiles/$TAG"
52+
gdal2tiles.py -z 0-8 -r near "$INNER" "$GITHUB_WORKSPACE/docs/assets/prism_tiles/$TAG"
53+
- name: Commit tiles
54+
run: |
55+
git config user.name "github-actions"
56+
git config user.email "actions@users.noreply.github.com"
57+
git add docs/assets/prism_tiles
58+
git commit -m "Add PRISM tiles ${{ inputs.tag }}" || echo "No changes"
59+
git push

docs/forecasting/prism/prism.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ tags:
77

88
PRISM (U.S. Gridded Climate)
99
================
10+
[▶ Open Interactive Map](../../prism-map.html){ .md-button .md-button--primary }
11+
1012
Ty Tuff, ESIIL
1113
2025-09-05
1214

@@ -21,6 +23,13 @@ PRISM is a widely used U.S. climate surface: terrain‑aware, quality‑controll
2123
* **Streamable:** each file is a **Cloud‑Optimized GeoTIFF** packaged in a **.zip** with predictable names; works cleanly with GDAL’s `/vsizip//vsicurl/`.
2224
* **Freshness signals:** the web service exposes endpoints for `releaseDate` and `gridCount` so you can decide when to refresh.
2325

26+
## Interactive preview
27+
28+
<iframe src="../../prism-map.html" title="PRISM Interactive Map"
29+
style="width:100%;height:70vh;border:1px solid #e5e7eb;border-radius:12px"></iframe>
30+
31+
[🧱 View Pre-tiled Demo](../../prism-tiles-demo.html){ .md-button }
32+
2433
---
2534

2635
## How to use it (copy/paste)

docs/prism-map.html

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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:'&copy; 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>

docs/prism-tiles-demo.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!doctype html>
2+
<html><head>
3+
<meta charset="utf-8"><title>PRISM Tiles Demo</title>
4+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
5+
<style>html,body,#map{height:100%;margin:0}</style>
6+
</head><body>
7+
<div id="map"></div>
8+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
9+
<script>
10+
const map = L.map('map').setView([39,-98], 5);
11+
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'&copy; OSM'}).addTo(map);
12+
// Replace TAG with your workflow input, e.g., tmax_2025-07-15_800m
13+
const TAG = 'tmax_2025-07-15_800m';
14+
L.tileLayer('./assets/prism_tiles/'+TAG+'/{z}/{x}/{y}.png', {opacity:0.75, maxZoom:8}).addTo(map);
15+
</script>
16+
</body></html>

0 commit comments

Comments
 (0)