factcircuit
FactCircuit는 불변 출처와 단계별 검증, 제한된 검색, 재현 가능한 평가를 제공하는 감사 가능한 주장·증거 검증 루프입니다
몬태나 메소넷 날씨·토양 데이터를 인터랙티브 지도에서 확인, 최신 상황과 시간·일별 이력 제공
Explore Montana Mesonet weather and soil data on an interactive map — latest conditions plus hourly and daily history for every station.
데이터JavaScript · HTMLGitHub @mt-climate-office온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기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 |