prop/lib/microwaveprop_web/live/weather_map_live.ex
Graham McIntire aa8b789d2e
feat(weather/map): persist viewport to URL for reload-stable view
Adds lat/lon/zoom URL params to /weather so reload / share-link
restores the exact map state. The existing ?layer= param keeps
working alongside.

LiveView side:

- mount() reads lat/lon/zoom from query params, clamps to valid
  ranges (lat -90..90, lon -180..180, zoom 4..10), and falls back
  to DFW@z7 when missing or invalid. Initial values flow to the
  hook via three new data attributes.
- handle_event("viewport_changed", ...) accepts {lat, lon, zoom}
  pushed by the hook on debounced moveend, clamps + rounds, and
  push_patches the URL with replace: true so browser history
  doesn't grow an entry per pan tick.
- build_path/2 helper centralises URL construction so select_layer
  and viewport_changed both produce a deterministic param order
  (layer, lat, lon, zoom). Tests assert exact URLs.

JS hook side:

- mounted() reads data-initial-lat/lon/zoom from the el dataset
  with parseFloat/parseInt + Number.isFinite gates, then seeds
  Leaflet's center/zoom from those instead of the previous
  hardcoded (32.897, -97.038, 7).
- moveend handler now also schedules a 400 ms-debounced
  pushEvent("viewport_changed", {lat, lon, zoom}) so the URL
  catches up with whatever the user panned/zoomed to. Separate
  debounce timer from the cells fetch since the URL push and the
  network fetch have different cadence sweet spots.

Tests cover: initial seeding from URL params, clamping of bad
values, viewport_changed event producing the right patched URL,
and the original layer-shareability test (now expecting the full
viewport in the URL).
2026-04-30 09:10:36 -05:00

640 lines
23 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.WeatherMapLive do
@moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers
@layers MapLayers.all()
@default_layer MapLayers.default_id()
# Default map viewport. Used when no `lat`/`lon`/`zoom` URL params are
# provided (the deep-link case). Centered on DFW (the project's home
# area) at z=7 — same defaults the JS hook used to hardcode.
@default_center_lat 32.897
@default_center_lon -97.038
@default_zoom 7
@min_zoom 4
@max_zoom 10
@impl true
def mount(params, _session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
end
# Default the timeline cursor to the valid_time closest to "now" so
# users land on current conditions, not a +11h forecast hour (which
# the underlying cache can advance to while the worker is walking
# f00→f18).
#
# Mount stays cheap — no grid materialisation. The dead render
# would otherwise spend ~510 s materialising 92k cells through
# `WeatherLayers.derive`, blocking the HTTP response and tripping
# the LiveView socket join timeout. The client now paints weather
# through a tile endpoint, so mount only needs the selected layer
# and valid_time metadata.
valid_times = recent_valid_times(Weather.available_weather_valid_times())
initial_vt = pick_initial_valid_time(valid_times)
{lat, lon, zoom} = parse_viewport_params(params)
{:ok,
assign(socket,
page_title: "Weather Map",
layers: @layers,
selected_layer: @default_layer,
valid_time: initial_vt,
valid_times: valid_times,
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
initial_selected_time: initial_vt && DateTime.to_iso8601(initial_vt),
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
initial_center_lat: lat,
initial_center_lon: lon,
initial_zoom: zoom,
current_lat: lat,
current_lon: lon,
current_zoom: zoom,
grid_visible: false,
radar_visible: false
)}
end
@impl true
def handle_params(params, _uri, socket) do
selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
# Update current viewport so subsequent push_patch URLs combine
# `layer=` with whatever lat/lon/zoom the user has navigated to.
# The map hook is `phx-update="ignore"` so re-rendering with new
# data attributes won't re-init Leaflet.
{lat, lon, zoom} = parse_viewport_params(params)
{:noreply,
socket
|> assign(:selected_layer, selected)
|> assign(:current_lat, lat)
|> assign(:current_lon, lon)
|> assign(:current_zoom, zoom)}
end
defp pick_layer(layer, current) when is_binary(layer) do
if MapLayers.valid_id?(layer), do: layer, else: pick_layer(nil, current)
end
defp pick_layer(_layer, current) when is_binary(current), do: current
defp pick_layer(_layer, _current), do: @default_layer
# Parse `lat` / `lon` / `zoom` query params, falling back to defaults
# when missing or out of range. Lat in [-90, 90], lon in [-180, 180],
# zoom in [@min_zoom, @max_zoom]. A bad single param doesn't poison
# the others — each clamps independently.
defp parse_viewport_params(params) do
lat = parse_float_param(params["lat"], -90.0, 90.0, @default_center_lat)
lon = parse_float_param(params["lon"], -180.0, 180.0, @default_center_lon)
zoom =
case Integer.parse(params["zoom"] || "") do
{z, ""} when z >= @min_zoom and z <= @max_zoom -> z
_ -> @default_zoom
end
{lat, lon, zoom}
end
defp parse_float_param(value, min, max, default) when is_binary(value) do
case Float.parse(value) do
{f, ""} when f >= min and f <= max -> f
_ -> default
end
end
defp parse_float_param(_value, _min, _max, default), do: default
defp pick_initial_valid_time([]), do: nil
defp pick_initial_valid_time(valid_times) do
now = DateTime.utc_now()
Enum.min_by(valid_times, fn vt -> abs(DateTime.diff(vt, now, :second)) end)
end
# Drop valid_times older than one hour ago. The HRRR scalar cache can
# hold the previous run's forecast hours past their useful life;
# showing 3-hour-old "current conditions" on the map is more
# misleading than helpful.
defp recent_valid_times(valid_times) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, cutoff) != :lt end)
end
# Build a `/weather?…` path that preserves the current
# layer + lat/lon/zoom. `overrides` lets a specific event (e.g.
# `select_layer`) tweak one field without losing the others.
defp build_path(socket, overrides \\ []) do
layer = Keyword.get(overrides, :layer, socket.assigns.selected_layer)
lat = Keyword.get(overrides, :lat, socket.assigns.current_lat)
lon = Keyword.get(overrides, :lon, socket.assigns.current_lon)
zoom = Keyword.get(overrides, :zoom, socket.assigns.current_zoom)
# List form (not map) so the param order is stable — tests
# asserting on the exact URL, and humans reading shareable links,
# both benefit from layer first then viewport.
query =
URI.encode_query([
{"layer", layer},
{"lat", lat},
{"lon", lon},
{"zoom", zoom}
])
"/weather?#{query}"
end
@impl true
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
if MapLayers.valid_id?(layer_id) do
{:noreply, push_patch(socket, to: build_path(socket, layer: layer_id))}
else
{:noreply, socket}
end
end
# Debounced moveend/zoomend from the JS hook. Every viewport change
# patches the URL so a refresh / share-link lands the user back at
# the same view. Stays out of the LiveView assigns hot path —
# `phx-update="ignore"` on the map div means the hook isn't re-run.
def handle_event("viewport_changed", %{"lat" => lat, "lon" => lon, "zoom" => zoom}, socket)
when is_number(lat) and is_number(lon) and is_number(zoom) do
new_lat = lat |> max(-90.0) |> min(90.0) |> Float.round(4)
new_lon = lon |> max(-180.0) |> min(180.0) |> Float.round(4)
new_zoom = zoom |> max(@min_zoom) |> min(@max_zoom) |> trunc()
socket =
socket
|> assign(:current_lat, new_lat)
|> assign(:current_lon, new_lon)
|> assign(:current_zoom, new_zoom)
{:noreply, push_patch(socket, to: build_path(socket), replace: true)}
end
def handle_event("viewport_changed", _params, socket), do: {:noreply, socket}
def handle_event("toggle_grid", _params, socket) do
visible = !socket.assigns.grid_visible
socket =
socket
|> assign(:grid_visible, visible)
|> push_event("toggle_grid", %{visible: visible})
{:noreply, socket}
end
def handle_event("toggle_radar", _params, socket) do
visible = !socket.assigns.radar_visible
socket =
socket
|> assign(:radar_visible, visible)
|> push_event("toggle_radar", %{visible: visible})
{:noreply, socket}
end
def handle_event("select_time", %{"time" => time_str}, socket) do
with {:ok, time, _} <- DateTime.from_iso8601(time_str),
true <- time in socket.assigns.valid_times do
socket =
socket
|> assign(:valid_time, time)
|> push_event("update_weather_overlay", %{selected: DateTime.to_iso8601(time)})
{:noreply, socket}
else
_ -> {:noreply, socket}
end
end
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
detail =
if socket.assigns.valid_time do
Weather.weather_point_detail(lat, lon, socket.assigns.valid_time)
end
payload = detail || %{}
{:noreply, push_event(socket, "point_detail", payload)}
end
@impl true
def handle_info({:weather_updated, _valid_time}, socket) do
valid_times = recent_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 =
cond do
socket.assigns.valid_time in valid_times -> socket.assigns.valid_time
valid_times != [] -> List.last(valid_times)
true -> nil
end
socket =
socket
|> assign(:valid_times, valid_times)
|> assign(:valid_time, selected)
|> push_event("update_weather_overlay", %{selected: selected && DateTime.to_iso8601(selected)})
|> push_event("update_timeline", %{
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
selected: selected && DateTime.to_iso8601(selected)
})
{:noreply, socket}
end
# A new forecast hour has landed. Re-enumerate the available times
# so the timeline picks up the extra hour, but keep the currently
# selected time where it is.
def handle_info({:propagation_pipeline_progress, _progress}, socket) do
valid_times = recent_valid_times(Weather.available_weather_valid_times())
if valid_times == socket.assigns.valid_times do
{:noreply, socket}
else
socket =
socket
|> assign(:valid_times, valid_times)
|> push_event("update_timeline", %{
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
selected: socket.assigns.valid_time && DateTime.to_iso8601(socket.assigns.valid_time)
})
{:noreply, socket}
end
end
@group_order ["Surface", "Upper Air", "Ducting"]
defp group_layers(layers) do
layers
|> Enum.group_by(& &1.group)
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
end
defp layer_description(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{desc: desc} -> desc
_ -> nil
end
end
defp selected_layer_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{label: label} -> label
_ -> ""
end
end
defp selected_layer_group_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{group: group} -> group
_ -> ""
end
end
@impl true
def render(assigns) do
~H"""
<div class="flex w-screen h-screen overflow-hidden">
<%!-- Map container --%>
<div class="relative flex-1 min-w-0">
<%!-- Full-page map --%>
<div
id="weather-map"
phx-hook="WeatherMap"
phx-update="ignore"
data-layers={Jason.encode!(@layers)}
data-selected-layer={@selected_layer}
data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time}
data-initial-lat={@initial_center_lat}
data-initial-lon={@initial_center_lon}
data-initial-zoom={@initial_zoom}
class="absolute inset-0 z-0"
>
</div>
<%!-- Bottom forecast timeline --%>
<div
id="weather-forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<%!-- Mobile-only floating controls --%>
<div
id="weather-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-mobile"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div id="weather-panel-extras" class="hidden flex-col gap-2">
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="text-[11px] opacity-70 px-1 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
<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">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
</.link>
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
EME
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<%!-- Point detail panel --%>
<div
id="weather-detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<button
id="weather-sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<%!-- Desktop right sidebar --%>
<div
id="weather-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</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)} &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">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<%!-- Navigation --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/map">Propagation Map</.link>
</li>
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/eme">EME</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li>
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
{@current_scope.user.callsign}
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end