尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证A map-based data explorer for the Montana Mesonet
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗Interactive map explorer for Montana Mesonet station data — the successor to the Leaflet "latest data" map at mesonet.climate.umt.edu/api/v2/map/latest/. A service of the Montana Climate Office.
Live: mesonet.climate.umt.edu/explorer — also published at https://mt-climate-office.github.io/mesonet-explorer/
/ shortcut, flies to the station and opens its detail panel.Every parameter is optional and every one has a default, so the default view has a clean URL with no query string at all. A parameter appears only once its value differs from the default, and disappears again when you return to it — so the URL is always the shortest description of what you're looking at. All of them remain valid as input whether or not the app would emit them.
Two consequences worth knowing:
date/hour default to the newest data available, so a link with no date shows
the recipient's latest day rather than the sender's. That's right for "here
are current conditions" and wrong for "look at this specific time" — but the
latter always carries an explicit date, since a past date isn't the default.theme is emitted only when it differs from the viewer's OS preference. Your own
choice is remembered in localStorage regardless; the parameter exists so a link
can carry a deliberate one.The share button is the exception: it copies a fully specified URL with every
parameter written out, so the recipient sees precisely what the sharer saw —
regardless of their OS theme, their saved preferences, or what "today" happens to
be when they open it. (kbd is left out: it's the sharer's input preference, not
part of the view.) The address bar stays minimal either way.
| Param | Values | Default | Meaning |
|---|---|---|---|
mode | latest | hourly | daily | latest | Time mode |
var | registry key (e.g. air_temp, ppt_7, soil_vwc_mid, eto) | air_temp | Displayed variable |
date | YYYY-MM-DD | today (MT) | Hourly/Daily date, clamped to network history |
hour | 0–23 | last complete hour | Hourly mode hour (Mountain Time) |
units | us | si | us | Unit system |
net | hydromet, agrimet (space/comma list) | both | Visible sub-networks; omitted from the URL when both are on |
labels | on | off | on | Value labels on markers |
agg | min | max | avg | sum | stddev | variable's default | Aggregation function (Hourly/Daily, observed variables); only non-default choices appear in the URL |
scale | min,mid,max (each may be -) | automatic | Custom color-scale range/pivot for the current variable; setting min/max locks the scale across time steps |
ramp | ramp name, -r suffix = reversed (e.g. vik, batlow-r) | variable's default | Color ramp override (ColorBrewer + Crameri names) |
radar | on | off | NEXRAD overlay (Latest only) |
nodata | hide |
Example: ?mode=hourly&var=wind_spd&date=2026-07-01&hour=18&units=si&station=acemocca
A default load, by contrast, leaves the URL bare — no ? at all.
All station data come live from the Montana Mesonet API v2 (mesonet2.climate.umt.edu/api, CORS-open). The base URL is a single constant (const API = …) at the top of the app script in index.html:
| Endpoint | Used for |
|---|---|
/stations/ | Station metadata (fetched once at boot) |
/observations/grouped/ | Core observations, all three modes (latest=true, hour=true, or day=true + start_time/end_time); one fetch per mode/timestamp serves ~21 variables |
/derived/, /derived/hourly/, /derived/daily/ | Derived variables (feels-like, ET, GDD, frost depth, …), per element |
/latest/, /observations/hourly/, /observations/daily/ + data2 mesonet/soils/soil_params.json | Percent saturation and soil water potential, computed client-side from per-depth VWC and the mesonet-soils lab fits (the API retired both in Oct 2026; same math as mesonet-dashboard's web/src/core/ag/compute/soil.ts), then averaged into shallow/mid/deep bands |
/derived/ppt/ | Precipitation accumulation windows (Latest only) |
/derived/change/ | Soil-moisture change (Latest only; fetched in station batches — the unfiltered call times out) |
/latest/, /observations/hourly/, /observations/daily/ | VPD and well variables (elements= list); also all aggregation (agg_func=) queries |
/photos/ | Camera registry — fallback only, when the archive's schedule.json (below) can't be fetched |
Responses are cached per (source, units, timestamp) as promises (deduping in-flight requests); Latest-keyed entries are invalidated by the auto-refresh timer. All timestamps and date logic use America/Denver.
Photos come entirely from the Mesonet archive bucket behind data2.climate.umt.edu/mesonet (CORS-open). The camera registry is photos/schedule/schedule.json (~5 KB on the wire, fetched once at boot): which stations have a camera, the directions it shoots now and has ever shot, and its first month. The API's /photos/ metadata is only a fallback — it lags the archive (nine cameras live in September 2026 were missing from it, so their popups never asked for a photo). The images are immutable WebP under photos/webp/large/{station}/{station}_{DIR}_{YYYYMMDD}T{HHMMSS}Z.webp (a 320px thumb/ tree sits alongside). The timestamp is the capture slot in UTC — a Mountain wall-clock time such as 09:00 or 15:00, so it shifts an hour at each DST change; newer cameras shoot hourly. Nothing serves a time-matched photo, so the app works out which frames exist and picks the one nearest the time on screen: the newest from the past 24 h in Latest, the frame nearest the end of the selected hour in Hourly, and the frames nearest 9 AM and 3 PM in Daily. The caption shows the capture time.
Two sources, by mode:
ListObjectsV2 on data2.climate.umt.edu/mesonet/?list-type=2&prefix=…), one UTC day per direction — about 2 KB each and always the live state. The edge forwards only prefix (max-keys and start-after are dropped), which is why a listing is narrowed to a day. Today's listing is re-read on the 5-minute refresh cadence; past days are held for the session.photos/manifest/{station}/{station}_{YYYY-MM}.csv — one fetch of 70–250 KB that covers every day of that local month for every direction, so scrubbing through days costs nothing more. Rows are keyed to the local date, so a Mountain day never straddles two files. The manifest's webp_large column is blank for many frames whose WebP does exist (the hourly-trial frames and the newer midday slot), so when it is empty the path is derived from the capture time snapped to the nearest hour; a capture more than 30 min from any hour has no WebP at all. The tree is rewritten on every publish, so only the month containing today is re-read (on the refresh cadence, and only while today is on screen).Also in the archive but not used here: photos/manifest/latest.csv — every frame from the last few days across all stations (~60 KB compressed, refreshed each minute), the right feed for a fleet dashboard but heavier per refresh than a popup's ten 2 KB listings.
Both fixes key off the unit string parsed from the returned column names, so they become no-ops if the API is fixed:
/observations/grouped/ with units=si converts values but not column labels (e.g. returns 24.06 under "Air Temperature [°F]"). The app relabels without converting./derived/ppt/ has no units parameter (always U.S. units). The app converts values and relabels./observations/grouped/ ignores agg_func (always returns the default aggregation). Aggregation queries therefore go to /observations/hourly|daily/ with each variable's raw element ids; those endpoints prefix the returned column names ("Maximum Air Temperature @ 2 m"), which the app strips. Depth-band variables combine client-side (min of mins, max of maxes, average of averages) — sum/std-dev are disabled for them./derived/change/ returns a 504 when queried for the whole network at once; the app batches 40 stations per request.elements=etr on /derived/daily/ returns the same column as eto (Reference ET (a=0.23)), so only eto is exposed.Boundary overlays (data/*.geojson) are generated by data.R (tigris + rmapshaper, Census TIGER 2023) and shared with the mesonet-status map. Watershed boundaries (data/mt_hucs.fgb, loaded at first use) are a vendored copy of the MCO CDN's data.climate.umt.edu/mesonet/fgb/mt_hucs.fgb — that host resolves to a private IP on the UMT campus network, where Chrome's Local Network Access policy blocks fetches from public pages.
Deliberately zero-build, matching the MCO pattern (mesonet-status, snowpack explorer):
index.html (markup + inline CSS design tokens) and app.js (a single ES module, kept external so the Content-Security-Policy can pin scripts to 'self'). No framework, no bundler, no package.json.index.html, with font-display: block so first paint uses the real faces instead of swapping.localStorage['mco-theme'] is shared across MCO apps.index.html (--z-map-overlay … --z-toast). Add to a tier rather than inventing a number — MapLibre's own corner controls sit at z-index 2, so anything floating over the map has to clear that.COMPACT_MQ in app.js (max-width: 640px, max-height: 560px) decides behaviour — which edge the detail panel docks to, whether the sidebar is a fixture or a drawer, and where each control group lives. The CSS decides layout, and splits the two on purpose: the narrow rules are width-only (max-width: 640px), because a short-but-wide window has plenty of room for the full header and must not get the phone layout; short-viewport rules are height-only. Keep the JS query in sync with the two @media blocks marked NARROW LAYOUT and SHORT VIEWPORTS.CSP — a <meta http-equiv="Content-Security-Policy"> tag in index.html restricts scripts to 'self', the two pinned CDN hosts, and a sha256 hash of the inline theme-flash script, and enumerates every origin the app talks to (connect-src/img-src/font-src/style-src). GitHub Pages can't set headers, so the meta form is used; frame-ancestors is not enforceable there.
SRI — the MapLibre JS/CSS and flatgeobuf <script>/<link> tags are version-pinned and carry integrity hashes; a tampered CDN response fails to load instead of executing.
If you edit the inline theme-flash script (the small <script> block in <head>), recompute its CSP hash and update the sha256-… token in the CSP meta tag:
perl -0777 -ne 'print $1 if /<script>(.*?)<\/script>/s' index.html \
| openssl dgst -sha256 -binary | openssl base64 -A
A stale hash blocks the script and the page loads without a theme set (everything else still works). Editing app.js needs no hash changes — it's covered by script-src 'self'.
python -m http.server 8000
# → http://localhost:8000
No build step. The app talks to the production API directly.
cd test && npm install && npm test
Headless browser checks live in test/ — layout and reachability across
six viewport sizes, URL round-tripping and share links, map camera behaviour, and
the PNG export. They are a separate package so the app itself stays
dependency-free, and the runner starts its own static server, so nothing needs to
be running first. See test/README.md, in particular the two
notes on live-data drift and stale coordinates — the ways these checks mislead
you if you skim the output.
The URL parameters double as a manual harness. Useful deterministic states:
| URL | Checks |
|---|---|
?station=acemocca&legend=open | Station detail — docked bottom below 640 px wide or 560 px tall, docked right above. Assert its rect is inside the viewport, that elementFromPoint() over every link returns the panel, and that no station dot is covered by it |
?sidebar=closed | Sidebar collapsed; Montana re-fits to the wider strip |
?mode=daily at 390×844 | #control-bar.scrollWidth === clientWidth (nothing hidden) and #variable-select fully on-screen |
?legend=open at 844×390 | #legend fits inside #main |
?export=light / ?export=dark | Auto-downloads the PNG 1.5 s after first render — the output should stay pixel-identical across UI changes |
?theme=light | documentElement.dataset.theme is set, which also proves the pinned CSP hash still matches the inline theme script |
?kbd=off | / does not focus the search box; Esc still closes panels |
Pushing main is a production deploy, on two URLs. GitHub Pages publishes the repo root from main (Settings → Pages → Deploy from branch → main / /), and the same page is reverse-proxied at mesonet.climate.umt.edu/explorer/ by the handle /explorer* block in the mesonet_app Caddyfile, which rewrites the path to /mesonet-explorer and proxies to mt-climate-office.github.io.
Nothing on the server needs to change when this app changes — the proxy always serves whatever Pages has. Only the Caddyfile itself is deployed by hand: on the server, git pull in the mesonet_app checkout, then docker compose restart caddy.
The app is path-agnostic (all asset URLs are relative and share links are built from location.origin + location.pathname), so it works at either URL without a <base> tag. Canonical and Open Graph URLs point at the mesonet.climate.umt.edu/explorer/ address.
MIT © Montana Climate Office
| show |
| Hide no-data stations |
stale | show | hide | Show stale (> 3 h) stations (Latest only) |
counties | on | off | County-boundary overlay |
watersheds | on | off | HUC6 watershed overlay |
legend | collapsed | open | open | Legend state (it lives in the sidebar, so collapsing it no longer buys map back) |
sidebar | open | closed | open | Left control sidebar. Desktop only — on compact viewports it's a drawer that always starts closed |
theme | light | dark | OS preference | Color theme; emitted only when it differs from the OS preference |
kbd | off | on | Disable the / search shortcut (WCAG 2.1.4). Round-trips, so it survives a reload |
lng, lat, zoom | floats | Montana extent | Map camera. All three or none; omitted while the map is at the fitted extent |
station | station ID (e.g. acemocca) | — | Deep link: fly to + open the detail panel |
export | light | dark | — | Headless hook: forces the theme and auto-downloads a PNG after load |