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
|
timelineEl: HTMLElement | null
|
||||||
timelineData: string[]
|
timelineData: string[]
|
||||||
selectedTime: string | null
|
selectedTime: string | null
|
||||||
|
playbackTimer: ReturnType<typeof setInterval> | null
|
||||||
_escHandler: (e: KeyboardEvent) => void
|
_escHandler: (e: KeyboardEvent) => void
|
||||||
_layerObserver: MutationObserver
|
_layerObserver: MutationObserver
|
||||||
|
|
||||||
|
|
@ -100,6 +101,8 @@ interface WeatherMapHook extends ViewHook {
|
||||||
buildLegend(this: WeatherMapHook): HTMLElement
|
buildLegend(this: WeatherMapHook): HTMLElement
|
||||||
renderTimeline(this: WeatherMapHook): void
|
renderTimeline(this: WeatherMapHook): void
|
||||||
selectTimelineTime(this: WeatherMapHook, time: string): void
|
selectTimelineTime(this: WeatherMapHook, time: string): void
|
||||||
|
startPlayback(this: WeatherMapHook): void
|
||||||
|
stopPlayback(this: WeatherMapHook): void
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
@ -515,7 +518,7 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
})
|
})
|
||||||
this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] })
|
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.onAdd = () => this.buildLegend()
|
||||||
this.legend.addTo(this.map)
|
this.legend.addTo(this.map)
|
||||||
|
|
||||||
|
|
@ -523,6 +526,7 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
this.timelineEl = document.getElementById("weather-forecast-timeline")
|
this.timelineEl = document.getElementById("weather-forecast-timeline")
|
||||||
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
|
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
|
||||||
this.selectedTime = this.el.dataset.selectedTime || null
|
this.selectedTime = this.el.dataset.selectedTime || null
|
||||||
|
this.playbackTimer = null
|
||||||
|
|
||||||
if (this.timelineData.length > 1) {
|
if (this.timelineData.length > 1) {
|
||||||
this.renderTimeline()
|
this.renderTimeline()
|
||||||
|
|
@ -539,19 +543,13 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
L.DomEvent.disableClickPropagation(this.timelineEl)
|
L.DomEvent.disableClickPropagation(this.timelineEl)
|
||||||
L.DomEvent.disableScrollPropagation(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) {
|
destroyed(this: WeatherMapHook) {
|
||||||
if (this._layerObserver) this._layerObserver.disconnect()
|
if (this._layerObserver) this._layerObserver.disconnect()
|
||||||
if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
|
if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
|
||||||
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
|
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
|
||||||
|
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
|
||||||
},
|
},
|
||||||
|
|
||||||
sendBounds(this: WeatherMapHook) {
|
sendBounds(this: WeatherMapHook) {
|
||||||
|
|
@ -749,25 +747,105 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
}).join("")
|
}).join("")
|
||||||
|
|
||||||
const labelText = mobile ? "Forecast" : "Weather Forecast"
|
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.style.display = "block"
|
||||||
this.timelineEl.innerHTML = `
|
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);">
|
<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}
|
${buttons}
|
||||||
</div>`
|
</div>`
|
||||||
|
|
||||||
this.timelineEl.querySelectorAll<HTMLButtonElement>("button[data-time]").forEach(btn => {
|
this.timelineEl.querySelectorAll<HTMLButtonElement>("button[data-time]").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const time = btn.dataset.time!
|
const time = btn.dataset.time!
|
||||||
|
// A manual click interrupts playback — the user has taken over.
|
||||||
|
this.stopPlayback()
|
||||||
this.selectTimelineTime(time)
|
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) {
|
selectTimelineTime(this: WeatherMapHook, time: string) {
|
||||||
this.selectedTime = time
|
this.selectedTime = time
|
||||||
this.renderTimeline()
|
this.renderTimeline()
|
||||||
this.pushEvent("select_time", { time })
|
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
|
} as unknown as WeatherMapHook
|
||||||
|
|
|
||||||
|
|
@ -143,13 +143,14 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
||||||
end
|
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()
|
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,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
@ -157,10 +158,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
layers: @layers,
|
layers: @layers,
|
||||||
selected_layer: "refractivity_gradient",
|
selected_layer: "refractivity_gradient",
|
||||||
initial_data_json: Jason.encode!(data),
|
initial_data_json: Jason.encode!(data),
|
||||||
valid_time: selected_time,
|
valid_time: latest_vt,
|
||||||
valid_times: valid_times,
|
valid_times: valid_times,
|
||||||
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
|
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"),
|
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
|
||||||
bounds: @initial_bounds,
|
bounds: @initial_bounds,
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
|
|
@ -168,14 +169,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
)}
|
)}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
|
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
|
||||||
{:noreply, assign(socket, :selected_layer, layer_id)}
|
{:noreply, assign(socket, :selected_layer, layer_id)}
|
||||||
|
|
@ -248,10 +241,16 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
def handle_info({:weather_updated, _valid_time}, socket) do
|
def handle_info({:weather_updated, _valid_time}, socket) do
|
||||||
valid_times = Weather.available_weather_valid_times()
|
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 =
|
selected =
|
||||||
if socket.assigns.valid_time in valid_times,
|
cond do
|
||||||
do: socket.assigns.valid_time,
|
socket.assigns.valid_time in valid_times -> socket.assigns.valid_time
|
||||||
else: closest_to_now(valid_times)
|
valid_times != [] -> List.last(valid_times)
|
||||||
|
true -> nil
|
||||||
|
end
|
||||||
|
|
||||||
data =
|
data =
|
||||||
if selected, do: Weather.weather_grid_at(selected, socket.assigns.bounds), else: []
|
if selected, do: Weather.weather_grid_at(selected, socket.assigns.bounds), else: []
|
||||||
|
|
@ -339,23 +338,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
>
|
>
|
||||||
</div>
|
</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 --%>
|
<%!-- Bottom forecast timeline --%>
|
||||||
<div
|
<div
|
||||||
id="weather-forecast-timeline"
|
id="weather-forecast-timeline"
|
||||||
|
|
@ -566,6 +548,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
</div>
|
</div>
|
||||||
</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 --%>
|
<%!-- Overlay toggles --%>
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
<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">
|
<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)
|
ProfilesFile.write!(valid_time, grid_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "layer description overlay" do
|
describe "layer description in sidebar" do
|
||||||
test "renders the description in a top-right overlay, not in the sidebar", %{conn: conn} do
|
test "renders Group · Label header and description in the desktop sidebar", %{conn: conn} do
|
||||||
{:ok, _lv, html} = live(conn, ~p"/weather")
|
{:ok, _lv, html} = live(conn, ~p"/weather")
|
||||||
|
|
||||||
assert html =~ ~s(id="weather-layer-info")
|
# Default layer is refractivity_gradient → group Surface, label N-Gradient.
|
||||||
# Default layer is refractivity_gradient — its description should
|
assert html =~ "Surface"
|
||||||
# appear inside the overlay element.
|
assert html =~ "N-Gradient"
|
||||||
assert html =~ ~r/id="weather-layer-info".*?Minimum refractivity gradient/s
|
assert html =~ "Minimum refractivity gradient"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue