Both used to be their own separate sidebar icons; neither has anything to manage beyond on/off, same as Wikipedia already got its own Overlays entry for. Terrain shading is a plain checkbox item now. Radar keeps its scrub/forecast controls, but behind an explicit expand (clicking its name), separate from the checkbox — checking it on shouldn't force the details open, and expanding to scrub/check the forecast shouldn't also flip the layer on. Both controls now build their own <li> (RadarControl.mount(li), HillshadeControl.mount(li) — the latter moved into overlays.js entirely, it has no reason to live in map.js once it's not building its own sidebar icon) rather than a whole sidebar-item + flyout the way they used to. Also gave each Overlays item a leading icon: Wikipedia and Terrain shading reuse existing NPS pictograms (WIKI_ICON, and WILDERNESS_ICON newly exported from icons.js's forest entry — NPS has no dedicated mountain/terrain glyph, wilderness-22.svg is the closest honest match). Radar's is hand-drawn (a rain cloud) since NPS's set has nothing weather-related to reuse.
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
import { RADAR_CLOUD_ICON } from "/js/icons.js";
|
|
|
|
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
|
|
|
|
// Open-Meteo: free, no API key. Only the forecast (small JSON) is cached
|
|
// here, in localStorage — the radar tile images above aren't: actually
|
|
// serving those without a live network needs a Service Worker (intercepting
|
|
// the tile requests and answering from a cache), which is its own separate
|
|
// piece of infrastructure (registration, cache strategy, invalidation) and
|
|
// deliberately left for later rather than folded into this. A cached
|
|
// forecast still gives *something* useful once genuinely offline, which the
|
|
// radar animation above can't.
|
|
const FORECAST_API = "https://api.open-meteo.com/v1/forecast";
|
|
const FORECAST_DAYS = 3;
|
|
const FORECAST_CACHE_KEY = "maptool:forecast";
|
|
|
|
// A small subset of WMO weather codes (open-meteo's `weather_code`) mapped
|
|
// to one glyph each — not the full table, just enough to tell clear/cloudy/
|
|
// rain/snow/storm apart at a glance.
|
|
const WEATHER_EMOJI = [
|
|
[[0], "☀️"],
|
|
[[1, 2, 3], "⛅"],
|
|
[[45, 48], "🌫️"],
|
|
[[51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82], "🌧️"],
|
|
[[71, 73, 75, 77, 85, 86], "❄️"],
|
|
[[95, 96, 99], "⛈️"],
|
|
];
|
|
|
|
function weatherEmoji(code) {
|
|
return WEATHER_EMOJI.find(([codes]) => codes.includes(code))?.[1] ?? "🌡️";
|
|
}
|
|
|
|
// Matches the default window used by github.com/jpettitt/weather-radar-card
|
|
// (60 minutes of history at RainViewer's native 10-minute cadence = 7
|
|
// frames, the last one being "now").
|
|
const FRAME_COUNT = 7;
|
|
const FRAME_INTERVAL_MS = 600;
|
|
const OPACITY = 0.7;
|
|
const ATTRIBUTION =
|
|
'Weather data © <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>';
|
|
|
|
// An Overlays panel item (see overlays.js) that fetches RainViewer's recent
|
|
// precipitation frames on demand and animates them as a raster overlay,
|
|
// with play/pause and a scrub slider revealed by expanding the item. No API
|
|
// key needed — see docs/architecture.adoc for RainViewer's usage terms.
|
|
export class RadarControl {
|
|
constructor(map) {
|
|
this._map = map;
|
|
this._frames = [];
|
|
this._frameIndex = 0;
|
|
this._enabled = false;
|
|
this._playing = false;
|
|
this._timer = null;
|
|
|
|
// setStyle() (switching the Online/Offline base map) tears down all
|
|
// custom sources and layers — put the radar overlay back afterwards.
|
|
map.on("style.load", () => {
|
|
if (this._enabled && this._frames.length) this._addLayers();
|
|
});
|
|
}
|
|
|
|
// li: the <li class="overlays-panel__item"> this item owns (see
|
|
// overlays.js) — built here rather than in the static markup since this
|
|
// control is the only thing that knows its own internal structure
|
|
// (playback/forecast), same as before when it built its own sidebar
|
|
// flyout.
|
|
mount(li) {
|
|
li.innerHTML = `
|
|
<div class="overlays-panel__row">
|
|
<input type="checkbox" class="overlays-panel__toggle">
|
|
<span class="overlays-panel__icon">${RADAR_CLOUD_ICON}</span>
|
|
<button type="button" class="overlays-panel__name">Radar</button>
|
|
</div>
|
|
<div class="overlays-panel__details" hidden>
|
|
<div class="radar-control__playback">
|
|
<button type="button" class="radar-control__play" aria-label="Play">▶</button>
|
|
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
|
|
<span class="radar-control__time"></span>
|
|
</div>
|
|
<div class="radar-control__forecast"></div>
|
|
</div>
|
|
`;
|
|
|
|
this._checkbox = li.querySelector(".overlays-panel__toggle");
|
|
this._nameButton = li.querySelector(".overlays-panel__name");
|
|
this._details = li.querySelector(".overlays-panel__details");
|
|
this._playback = li.querySelector(".radar-control__playback");
|
|
this._playButton = li.querySelector(".radar-control__play");
|
|
this._scrub = li.querySelector(".radar-control__scrub");
|
|
this._time = li.querySelector(".radar-control__time");
|
|
this._forecastEl = li.querySelector(".radar-control__forecast");
|
|
|
|
this._checkbox.addEventListener("change", (event) => this._setEnabled(event.target.checked));
|
|
// Separate from the checkbox on purpose — expanding to see the scrub/
|
|
// forecast shouldn't also flip the layer on, and turning the layer on
|
|
// shouldn't force the details open (a viewer who never cares to scrub
|
|
// history/see the forecast keeps a one-line item either way).
|
|
this._nameButton.addEventListener("click", () => {
|
|
const expanding = this._details.hidden;
|
|
this._details.hidden = !expanding;
|
|
this._nameButton.setAttribute("aria-expanded", String(expanding));
|
|
});
|
|
this._playButton.addEventListener("click", () => this._togglePlayback());
|
|
this._scrub.addEventListener("input", () => {
|
|
this._stopAnimation();
|
|
this._showFrame(Number(this._scrub.value));
|
|
});
|
|
}
|
|
|
|
async _setEnabled(enabled) {
|
|
this._enabled = enabled;
|
|
if (!enabled) {
|
|
this._stopAnimation();
|
|
this._removeLayers();
|
|
this._frames = [];
|
|
this._updateFrameUI();
|
|
this._renderForecast(null);
|
|
return;
|
|
}
|
|
|
|
this._checkbox.disabled = true;
|
|
// Independent fetches: RainViewer being unreachable shouldn't hide a
|
|
// forecast that Open-Meteo still answered, or vice versa — especially
|
|
// since only the forecast has a cached fallback to fall back to.
|
|
const [framesResult, forecastResult] = await Promise.allSettled([fetchFrames(), this._loadForecast()]);
|
|
this._checkbox.disabled = false;
|
|
|
|
// The checkbox may have been unchecked again (disabling) while the
|
|
// fetches were in flight.
|
|
if (!this._enabled) return;
|
|
|
|
const frames = framesResult.status === "fulfilled" ? framesResult.value : [];
|
|
const forecast = forecastResult.status === "fulfilled" ? forecastResult.value : null;
|
|
|
|
// Nothing at all came back (both offline, no cached forecast either) —
|
|
// same "turn back off" story the radar-only fetch used to have on its
|
|
// own failure.
|
|
if (frames.length === 0 && !forecast) {
|
|
this._enabled = false;
|
|
this._checkbox.checked = false;
|
|
} else if (frames.length > 0) {
|
|
this._frames = frames;
|
|
this._frameIndex = frames.length - 1;
|
|
this._addLayers();
|
|
this._startAnimation();
|
|
}
|
|
|
|
this._updateFrameUI();
|
|
this._renderForecast(forecast);
|
|
}
|
|
|
|
// cachedAt is null for a fresh fetch, or the cache's own timestamp when
|
|
// falling back to it — _renderForecast uses that to show a "cached" note
|
|
// instead of claiming this is live data.
|
|
async _loadForecast() {
|
|
const { lat, lng } = this._map.getCenter();
|
|
try {
|
|
const data = await fetchForecast(lat, lng);
|
|
writeCachedForecast(data);
|
|
return { data, cachedAt: null };
|
|
} catch (err) {
|
|
const cached = readCachedForecast();
|
|
if (!cached) throw err;
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
_renderForecast(result) {
|
|
if (!result) {
|
|
this._forecastEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const { data, cachedAt } = result;
|
|
const days = data.daily.time.map((date, i) => ({
|
|
date,
|
|
code: data.daily.weather_code[i],
|
|
max: Math.round(data.daily.temperature_2m_max[i]),
|
|
min: Math.round(data.daily.temperature_2m_min[i]),
|
|
precip: data.daily.precipitation_probability_max[i],
|
|
}));
|
|
|
|
const rows = days
|
|
.map(
|
|
(day) => `
|
|
<div class="radar-control__forecast-day">
|
|
<span>${new Date(day.date).toLocaleDateString([], { weekday: "short" })}</span>
|
|
<span>${weatherEmoji(day.code)}</span>
|
|
<span>${day.max}° / ${day.min}°</span>
|
|
<span>${day.precip}%</span>
|
|
</div>`,
|
|
)
|
|
.join("");
|
|
const staleNote = cachedAt
|
|
? `<p class="radar-control__forecast-stale">Cached from ${new Date(cachedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}</p>`
|
|
: "";
|
|
this._forecastEl.innerHTML = rows + staleNote;
|
|
}
|
|
|
|
_addLayers() {
|
|
const beforeId = firstSymbolLayerId(this._map);
|
|
this._frames.forEach((frame, i) => {
|
|
const id = layerId(i);
|
|
if (this._map.getSource(id)) return;
|
|
this._map.addSource(id, {
|
|
type: "raster",
|
|
tiles: [tileUrl(frame)],
|
|
tileSize: 256,
|
|
maxzoom: 7,
|
|
attribution: ATTRIBUTION,
|
|
});
|
|
this._map.addLayer(
|
|
{
|
|
id,
|
|
type: "raster",
|
|
source: id,
|
|
paint: { "raster-opacity": i === this._frameIndex ? OPACITY : 0 },
|
|
},
|
|
beforeId,
|
|
);
|
|
});
|
|
}
|
|
|
|
_removeLayers() {
|
|
this._frames.forEach((_, i) => {
|
|
const id = layerId(i);
|
|
if (this._map.getLayer(id)) this._map.removeLayer(id);
|
|
if (this._map.getSource(id)) this._map.removeSource(id);
|
|
});
|
|
}
|
|
|
|
_showFrame(index) {
|
|
this._frameIndex = index;
|
|
this._frames.forEach((_, i) => {
|
|
const id = layerId(i);
|
|
if (this._map.getLayer(id)) {
|
|
this._map.setPaintProperty(id, "raster-opacity", i === index ? OPACITY : 0);
|
|
}
|
|
});
|
|
this._updateFrameUI();
|
|
}
|
|
|
|
_startAnimation() {
|
|
if (this._frames.length < 2) return;
|
|
this._playing = true;
|
|
this._playButton.textContent = "⏸";
|
|
this._playButton.setAttribute("aria-label", "Pause");
|
|
this._timer = setInterval(() => {
|
|
this._showFrame((this._frameIndex + 1) % this._frames.length);
|
|
}, FRAME_INTERVAL_MS);
|
|
}
|
|
|
|
_stopAnimation() {
|
|
this._playing = false;
|
|
this._playButton.textContent = "▶";
|
|
this._playButton.setAttribute("aria-label", "Play");
|
|
if (this._timer) {
|
|
clearInterval(this._timer);
|
|
this._timer = null;
|
|
}
|
|
}
|
|
|
|
_togglePlayback() {
|
|
if (this._playing) this._stopAnimation();
|
|
else this._startAnimation();
|
|
}
|
|
|
|
_updateFrameUI() {
|
|
this._playback.hidden = this._frames.length === 0;
|
|
this._scrub.max = String(Math.max(0, this._frames.length - 1));
|
|
this._scrub.value = String(this._frameIndex);
|
|
const frame = this._frames[this._frameIndex];
|
|
this._time.textContent = frame
|
|
? new Date(frame.time * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
: "";
|
|
}
|
|
}
|
|
|
|
function layerId(i) {
|
|
return `radar-frame-${i}`;
|
|
}
|
|
|
|
function tileUrl(frame) {
|
|
// Color scheme 2 (Universal Blue), smoothing on, no snow overlay — see
|
|
// docs/architecture.adoc for the full RainViewer tile URL reference.
|
|
return `${frame.host}${frame.path}/256/{z}/{x}/{y}/2/1_0.png`;
|
|
}
|
|
|
|
async function fetchFrames() {
|
|
const response = await fetch(RAINVIEWER_API);
|
|
const data = await response.json();
|
|
const host = data.host || "https://tilecache.rainviewer.com";
|
|
const past = (data.radar && data.radar.past) || [];
|
|
return past.slice(-FRAME_COUNT).map((frame) => ({ ...frame, host }));
|
|
}
|
|
|
|
async function fetchForecast(lat, lon) {
|
|
const url = new URL(FORECAST_API);
|
|
url.searchParams.set("latitude", lat);
|
|
url.searchParams.set("longitude", lon);
|
|
url.searchParams.set("daily", "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max");
|
|
url.searchParams.set("timezone", "auto");
|
|
url.searchParams.set("forecast_days", String(FORECAST_DAYS));
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`Open-Meteo request failed: ${response.status}`);
|
|
return response.json();
|
|
}
|
|
|
|
// localStorage, not IndexedDB: this is one small JSON object (a few days of
|
|
// daily figures), not worth a heavier async storage API for. Wrapped since
|
|
// private browsing / storage-blocked contexts can throw on access, not just
|
|
// on being full — a forecast is a nice-to-have, never worth crashing over.
|
|
function writeCachedForecast(data) {
|
|
try {
|
|
localStorage.setItem(FORECAST_CACHE_KEY, JSON.stringify({ data, cachedAt: Date.now() }));
|
|
} catch (err) {
|
|
// Ignore — see above.
|
|
}
|
|
}
|
|
|
|
function readCachedForecast() {
|
|
try {
|
|
const raw = localStorage.getItem(FORECAST_CACHE_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function firstSymbolLayerId(map) {
|
|
const layers = map.getStyle()?.layers || [];
|
|
const symbolLayer = layers.find((layer) => layer.type === "symbol");
|
|
return symbolLayer ? symbolLayer.id : undefined;
|
|
}
|