diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 25da1bd6..8630fd9f 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -88,6 +88,7 @@ interface WeatherMapHook extends ViewHook { timelineEl: HTMLElement | null timelineData: string[] selectedTime: string | null + playbackTimer: ReturnType | null _escHandler: (e: KeyboardEvent) => void _layerObserver: MutationObserver @@ -100,6 +101,8 @@ interface WeatherMapHook extends ViewHook { buildLegend(this: WeatherMapHook): HTMLElement renderTimeline(this: WeatherMapHook): void selectTimelineTime(this: WeatherMapHook, time: string): void + startPlayback(this: WeatherMapHook): void + stopPlayback(this: WeatherMapHook): void } // --- Helpers --- @@ -515,7 +518,7 @@ export const WeatherMap: WeatherMapHook = { }) this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] }) - this.legend = L.control({ position: isMobile() ? "topright" : "bottomright" }) + this.legend = L.control({ position: "topright" }) this.legend.onAdd = () => this.buildLegend() this.legend.addTo(this.map) @@ -523,6 +526,7 @@ export const WeatherMap: WeatherMapHook = { this.timelineEl = document.getElementById("weather-forecast-timeline") this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]") this.selectedTime = this.el.dataset.selectedTime || null + this.playbackTimer = null if (this.timelineData.length > 1) { this.renderTimeline() @@ -539,19 +543,13 @@ export const WeatherMap: WeatherMapHook = { L.DomEvent.disableClickPropagation(this.timelineEl) L.DomEvent.disableScrollPropagation(this.timelineEl) } - - // Keep the layer-info overlay from passing through to the map. - const layerInfo = document.getElementById("weather-layer-info") - if (layerInfo) { - L.DomEvent.disableClickPropagation(layerInfo) - L.DomEvent.disableScrollPropagation(layerInfo) - } }, destroyed(this: WeatherMapHook) { if (this._layerObserver) this._layerObserver.disconnect() if (this._escHandler) document.removeEventListener("keydown", this._escHandler) if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer) + if (this.playbackTimer !== null) clearInterval(this.playbackTimer) }, sendBounds(this: WeatherMapHook) { @@ -749,25 +747,105 @@ export const WeatherMap: WeatherMapHook = { }).join("") const labelText = mobile ? "Forecast" : "Weather Forecast" + const isPlaying = this.playbackTimer !== null + const playBg = isPlaying ? "#7dffd4" : "rgba(255,255,255,0.15)" + const playFg = isPlaying ? "#000" : "#fff" + const ctrlFont = mobile ? "10px" : "11px" + const ctrlPad = mobile ? "1px 5px" : "2px 7px" this.timelineEl.style.display = "block" this.timelineEl.innerHTML = `
- ${labelText} +
+ ${labelText} +
+ + +
+
${buttons}
` this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => { btn.addEventListener("click", () => { const time = btn.dataset.time! + // A manual click interrupts playback — the user has taken over. + this.stopPlayback() this.selectTimelineTime(time) }) }) + + const playBtn = this.timelineEl.querySelector("button[data-timeline-play]") + if (playBtn) playBtn.addEventListener("click", () => this.startPlayback()) + + const stopBtn = this.timelineEl.querySelector("button[data-timeline-stop]") + if (stopBtn) stopBtn.addEventListener("click", () => this.stopPlayback()) }, selectTimelineTime(this: WeatherMapHook, time: string) { this.selectedTime = time this.renderTimeline() this.pushEvent("select_time", { time }) + }, + + startPlayback(this: WeatherMapHook) { + // Restarting re-seeds at "now" so repeated clicks don't resume + // mid-loop — users expect "play from the start". + if (this.playbackTimer !== null) { + clearInterval(this.playbackTimer) + this.playbackTimer = null + } + + if (this.timelineData.length < 2) return + + const now = Date.now() + const nowIdx = this.timelineData.reduce((best, time, i) => { + const d = Math.abs(new Date(time).getTime() - now) + const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now) + return d < bestD ? i : best + }, 0) + + // Iterate "now" forward through the end of the forecast then loop. + const playable = this.timelineData.slice(nowIdx) + if (playable.length < 2) return + + let cursor = 0 + const step = () => { + this.selectTimelineTime(playable[cursor]) + cursor = (cursor + 1) % playable.length + } + + // Assign the handle before the first step so renderTimeline sees + // playbackTimer !== null and paints Play as active. + this.playbackTimer = setInterval(step, 1000) + step() + }, + + stopPlayback(this: WeatherMapHook) { + if (this.playbackTimer !== null) { + clearInterval(this.playbackTimer) + this.playbackTimer = null + } + + if (this.timelineData.length === 0) { + this.renderTimeline() + return + } + + const now = Date.now() + const nowIdx = this.timelineData.reduce((best, time, i) => { + const d = Math.abs(new Date(time).getTime() - now) + const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now) + return d < bestD ? i : best + }, 0) + this.selectTimelineTime(this.timelineData[nowIdx]) } } as unknown as WeatherMapHook diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index b840fe21..e66bef49 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -143,13 +143,14 @@ defmodule MicrowavepropWeb.WeatherMapLive do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline") end + # Hot-path mount: use the cache-only latest_weather_grid for the + # initial payload so mount stays under the LV socket join timeout + # (~10s). Reading and deriving the full 92k-point ProfilesFile + # synchronously pushes mount past that window on a cold pod, + # which makes the LV client fall back to a full page reload. + data = Weather.latest_weather_grid(@initial_bounds) + latest_vt = if data == [], do: Weather.latest_grid_valid_time(), else: hd(data).valid_time valid_times = Weather.available_weather_valid_times() - selected_time = closest_to_now(valid_times) - - data = - if selected_time, - do: Weather.weather_grid_at(selected_time, @initial_bounds), - else: [] {:ok, assign(socket, @@ -157,10 +158,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do layers: @layers, selected_layer: "refractivity_gradient", initial_data_json: Jason.encode!(data), - valid_time: selected_time, + valid_time: latest_vt, valid_times: valid_times, initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)), - initial_selected_time: selected_time && DateTime.to_iso8601(selected_time), + initial_selected_time: latest_vt && DateTime.to_iso8601(latest_vt), initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"), bounds: @initial_bounds, grid_visible: false, @@ -168,14 +169,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do )} end - defp closest_to_now([]), do: nil - - defp closest_to_now(valid_times) do - now = DateTime.utc_now() - - Enum.min_by(valid_times, fn t -> abs(DateTime.diff(t, now, :second)) end) - end - @impl true def handle_event("select_layer", %{"layer" => layer_id}, socket) do {:noreply, assign(socket, :selected_layer, layer_id)} @@ -248,10 +241,16 @@ defmodule MicrowavepropWeb.WeatherMapLive do def handle_info({:weather_updated, _valid_time}, socket) do valid_times = Weather.available_weather_valid_times() + # Keep whatever the user has scrubbed to if it's still available, + # otherwise fall back to the latest on-disk time (GridCache-backed + # hot path). Never block mount/refresh on a ProfilesFile read for + # the happy path. selected = - if socket.assigns.valid_time in valid_times, - do: socket.assigns.valid_time, - else: closest_to_now(valid_times) + cond do + socket.assigns.valid_time in valid_times -> socket.assigns.valid_time + valid_times != [] -> List.last(valid_times) + true -> nil + end data = if selected, do: Weather.weather_grid_at(selected, socket.assigns.bounds), else: [] @@ -339,23 +338,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do > - <%!-- Layer description overlay (top-right of map area) --%> - - <%!-- Bottom forecast timeline --%>
+ <%!-- Layer description (group · name header + body) --%> +
+
+ {selected_layer_group_label(@layers, @selected_layer)} · {selected_layer_label( + @layers, + @selected_layer + )} +
+
+ {layer_description(@layers, @selected_layer)} +
+
+ <%!-- Overlay toggles --%>