WeatherMap: legend in top-right, description back in sidebar, play/stop, fix reload loop

Corrections to the previous weather-map pass:

* The "helper" that was supposed to live in the top-right was the
  color legend, not the layer description. Move the Leaflet legend
  control to `topright` (was `bottomright` on desktop).

* Put the layer description back in the desktop sidebar where it
  used to live, now with a "Group · Label" header above the body
  text so it matches the new style in the mockup.

* Add play/stop controls to the weather forecast timeline, ported
  from the propagation map: start plays "now" → end at 1s/step and
  loops; stop returns to the time closest to wall clock. A manual
  click interrupts playback the same way it does on /map.

* Fix the reconnect/reload loop I introduced by making mount do a
  synchronous ProfilesFile read + derive for the full 92k-point
  grid. That was pushing mount past the LV socket join timeout on
  a cold pod, which makes the LV client fall back to a full HTTP
  reload every ~20 seconds (no server-side error). Mount now uses
  the original `latest_weather_grid/1` cache-only hot path and only
  `weather_grid_at/2` (sync disk read) on user timeline scrubs.

* Clean up the playback timer in the hook's `destroyed` callback.
This commit is contained in:
Graham McIntire 2026-04-15 14:11:52 -05:00
parent ffc81b789e
commit 33347f7fc2
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 124 additions and 51 deletions

View file

@ -88,6 +88,7 @@ interface WeatherMapHook extends ViewHook {
timelineEl: HTMLElement | null
timelineData: string[]
selectedTime: string | null
playbackTimer: ReturnType<typeof setInterval> | 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 = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;margin-right:6px;flex-shrink:0;">${labelText}</span>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;margin-right:6px;flex-shrink:0;">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;">${labelText}</span>
<div style="display:flex;gap:3px;">
<button data-timeline-play style="
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
background:${playBg};color:${playFg};
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
cursor:pointer;" title="Play forecast animation"></button>
<button data-timeline-stop style="
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
background:rgba(255,255,255,0.15);color:#fff;
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
cursor:pointer;" title="Stop and return to now"></button>
</div>
</div>
${buttons}
</div>`
this.timelineEl.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>("button[data-timeline-play]")
if (playBtn) playBtn.addEventListener("click", () => this.startPlayback())
const stopBtn = this.timelineEl.querySelector<HTMLButtonElement>("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

View file

@ -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
>
</div>
<%!-- Layer description overlay (top-right of map area) --%>
<div
id="weather-layer-info"
data-theme="dark"
class="hidden md:block absolute top-3 right-3 z-[1000] max-w-sm bg-neutral text-neutral-content rounded-box border border-base-300 shadow-lg p-3"
>
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
{selected_layer_group_label(@layers, @selected_layer)} &middot; {selected_layer_label(
@layers,
@selected_layer
)}
</div>
<div class="text-xs opacity-80 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<%!-- Bottom forecast timeline --%>
<div
id="weather-forecast-timeline"
@ -566,6 +548,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
</div>
</div>
<%!-- Layer description (group · name header + body) --%>
<div class="px-1">
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
{selected_layer_group_label(@layers, @selected_layer)} &middot; {selected_layer_label(
@layers,
@selected_layer
)}
</div>
<div class="text-[11px] opacity-70 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<%!-- Overlay toggles --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">

View file

@ -40,14 +40,14 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
ProfilesFile.write!(valid_time, grid_data)
end
describe "layer description overlay" do
test "renders the description in a top-right overlay, not in the sidebar", %{conn: conn} do
describe "layer description in sidebar" do
test "renders Group · Label header and description in the desktop sidebar", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather")
assert html =~ ~s(id="weather-layer-info")
# Default layer is refractivity_gradient — its description should
# appear inside the overlay element.
assert html =~ ~r/id="weather-layer-info".*?Minimum refractivity gradient/s
# Default layer is refractivity_gradient → group Surface, label N-Gradient.
assert html =~ "Surface"
assert html =~ "N-Gradient"
assert html =~ "Minimum refractivity gradient"
end
end