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:
parent
ffc81b789e
commit
33347f7fc2
3 changed files with 124 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)} · {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)} · {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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue