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 = `
${RADAR_CLOUD_ICON}
`;
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 hourRows = nextHours(data.hourly, HOURLY_FORECAST_COUNT)
.map(
(hour) => `