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).
This commit is contained in:
Graham McIntire 2026-04-30 09:10:36 -05:00
parent ea27e84613
commit aa8b789d2e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 186 additions and 6 deletions

View file

@ -86,6 +86,7 @@ interface WeatherMapHook extends ViewHook {
packKey: string | null
inflightCellsAbort: AbortController | null
moveDebounce: ReturnType<typeof setTimeout> | null
viewportDebounce: ReturnType<typeof setTimeout> | null
detailPanel: HTMLElement | null
escHint: HTMLDivElement
clickMarker: L.LayerGroup
@ -122,6 +123,7 @@ interface WeatherMapHook extends ViewHook {
selectTimelineTime(this: WeatherMapHook, time: string): void
startPlayback(this: WeatherMapHook): void
stopPlayback(this: WeatherMapHook): void
pushViewport(this: WeatherMapHook): void
}
// --- Helpers ---
@ -651,9 +653,20 @@ function buildDetailHTML(point: WeatherPoint): string {
export const WeatherMap: WeatherMapHook = {
mounted(this: WeatherMapHook) {
// Initial center/zoom comes from `?lat=&lon=&zoom=` URL params via
// the LiveView, falling back to DFW@z7 when missing. Reload-friendly:
// the hook below pushes `viewport_changed` on every moveend/zoomend
// so the URL stays in sync.
const dataLat = parseFloat(this.el.dataset.initialLat || "")
const dataLon = parseFloat(this.el.dataset.initialLon || "")
const dataZoom = parseInt(this.el.dataset.initialZoom || "", 10)
const initialLat = Number.isFinite(dataLat) ? dataLat : 32.897
const initialLon = Number.isFinite(dataLon) ? dataLon : -97.038
const initialZoom = Number.isFinite(dataZoom) ? dataZoom : 7
this.map = L.map(this.el, {
center: [32.897, -97.038],
zoom: 7,
center: [initialLat, initialLon],
zoom: initialZoom,
minZoom: 4,
maxZoom: 10
})
@ -670,6 +683,7 @@ export const WeatherMap: WeatherMapHook = {
this.packKey = null
this.inflightCellsAbort = null
this.moveDebounce = null
this.viewportDebounce = null
const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.selectedLayer) {
@ -805,6 +819,13 @@ export const WeatherMap: WeatherMapHook = {
// Debounce so a rapid pan-zoom only fires one fetch.
if (this.moveDebounce) clearTimeout(this.moveDebounce)
this.moveDebounce = setTimeout(() => this.fetchAndPaintCells(), 250)
// Sync the URL with the current viewport so a reload lands the
// user at the same place. Separate debounce from the cells fetch
// — the URL push is cheap (server-side push_patch with replace:
// true) but we still don't want one per pixel of pan.
if (this.viewportDebounce) clearTimeout(this.viewportDebounce)
this.viewportDebounce = setTimeout(() => this.pushViewport(), 400)
})
// When the tab becomes visible again, refresh the overlay for the
@ -877,6 +898,7 @@ export const WeatherMap: WeatherMapHook = {
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
if (this.moveDebounce) clearTimeout(this.moveDebounce)
if (this.viewportDebounce) clearTimeout(this.viewportDebounce)
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
@ -884,6 +906,23 @@ export const WeatherMap: WeatherMapHook = {
}
},
// Push the current viewport (lat, lon, zoom) to the LiveView so the
// URL can be patched with `?lat=&lon=&zoom=`. Called from a debounced
// moveend handler so a rapid pan doesn't spam the channel. The
// server-side push_patch uses replace: true, so the browser history
// doesn't grow an entry per pan tick.
pushViewport(this: WeatherMapHook) {
if (!this.map) return
const center = this.map.getCenter()
const zoom = this.map.getZoom()
if (!Number.isFinite(center.lat) || !Number.isFinite(center.lng)) return
this.pushEvent("viewport_changed", {
lat: center.lat,
lon: center.lng,
zoom: zoom
})
},
reconnected(this: WeatherMapHook) {
if (this.map) {
this.map.invalidateSize()

View file

@ -8,8 +8,17 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@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
def mount(params, _session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
@ -30,6 +39,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
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",
@ -40,6 +51,12 @@ defmodule MicrowavepropWeb.WeatherMapLive do
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
)}
@ -48,7 +65,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@impl true
def handle_params(params, _uri, socket) do
selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
{:noreply, assign(socket, :selected_layer, selected)}
# 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
@ -58,6 +87,32 @@ defmodule MicrowavepropWeb.WeatherMapLive do
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
@ -74,15 +129,59 @@ defmodule MicrowavepropWeb.WeatherMapLive do
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: ~p"/weather?layer=#{layer_id}")}
{: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
@ -222,6 +321,9 @@ defmodule MicrowavepropWeb.WeatherMapLive do
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>

View file

@ -198,7 +198,46 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
render_hook(lv, "select_layer", %{"layer" => "pwat"})
assert_patched(lv, ~p"/weather?layer=pwat")
# URL now includes lat/lon/zoom alongside layer so a deep-link
# restores the full viewport, not just the active layer.
assert_patch(lv, "/weather?layer=pwat&lat=32.897&lon=-97.038&zoom=7")
end
end
describe "viewport URL state" do
test "lat/lon/zoom URL params seed the initial viewport assigns", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather?lat=53.32&lon=-60.42&zoom=6")
assert html =~ ~s(data-initial-lat="53.32")
assert html =~ ~s(data-initial-lon="-60.42")
assert html =~ ~s(data-initial-zoom="6")
end
test "missing/invalid viewport params fall back to DFW@z7", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather?lat=999&zoom=junk")
assert html =~ ~s(data-initial-lat="32.897")
assert html =~ ~s(data-initial-lon="-97.038")
assert html =~ ~s(data-initial-zoom="7")
end
test "viewport_changed event push_patches the URL with new lat/lon/zoom", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
render_hook(lv, "viewport_changed", %{"lat" => 45.5, "lon" => -73.6, "zoom" => 8})
# Default layer (refractivity_gradient) is preserved when only
# the viewport changes.
assert_patch(lv, "/weather?layer=temperature&lat=45.5&lon=-73.6&zoom=8")
end
test "viewport_changed clamps out-of-range values", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
render_hook(lv, "viewport_changed", %{"lat" => 999.0, "lon" => -200.0, "zoom" => 99})
# 999 clamps to 90, -200 clamps to -180, 99 clamps to maxZoom (10).
assert_patch(lv, "/weather?layer=temperature&lat=90.0&lon=-180.0&zoom=10")
end
end