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:
parent
ea27e84613
commit
aa8b789d2e
3 changed files with 186 additions and 6 deletions
|
|
@ -86,6 +86,7 @@ interface WeatherMapHook extends ViewHook {
|
||||||
packKey: string | null
|
packKey: string | null
|
||||||
inflightCellsAbort: AbortController | null
|
inflightCellsAbort: AbortController | null
|
||||||
moveDebounce: ReturnType<typeof setTimeout> | null
|
moveDebounce: ReturnType<typeof setTimeout> | null
|
||||||
|
viewportDebounce: ReturnType<typeof setTimeout> | null
|
||||||
detailPanel: HTMLElement | null
|
detailPanel: HTMLElement | null
|
||||||
escHint: HTMLDivElement
|
escHint: HTMLDivElement
|
||||||
clickMarker: L.LayerGroup
|
clickMarker: L.LayerGroup
|
||||||
|
|
@ -122,6 +123,7 @@ interface WeatherMapHook extends ViewHook {
|
||||||
selectTimelineTime(this: WeatherMapHook, time: string): void
|
selectTimelineTime(this: WeatherMapHook, time: string): void
|
||||||
startPlayback(this: WeatherMapHook): void
|
startPlayback(this: WeatherMapHook): void
|
||||||
stopPlayback(this: WeatherMapHook): void
|
stopPlayback(this: WeatherMapHook): void
|
||||||
|
pushViewport(this: WeatherMapHook): void
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
@ -651,9 +653,20 @@ function buildDetailHTML(point: WeatherPoint): string {
|
||||||
|
|
||||||
export const WeatherMap: WeatherMapHook = {
|
export const WeatherMap: WeatherMapHook = {
|
||||||
mounted(this: 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, {
|
this.map = L.map(this.el, {
|
||||||
center: [32.897, -97.038],
|
center: [initialLat, initialLon],
|
||||||
zoom: 7,
|
zoom: initialZoom,
|
||||||
minZoom: 4,
|
minZoom: 4,
|
||||||
maxZoom: 10
|
maxZoom: 10
|
||||||
})
|
})
|
||||||
|
|
@ -670,6 +683,7 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
this.packKey = null
|
this.packKey = null
|
||||||
this.inflightCellsAbort = null
|
this.inflightCellsAbort = null
|
||||||
this.moveDebounce = null
|
this.moveDebounce = null
|
||||||
|
this.viewportDebounce = null
|
||||||
|
|
||||||
const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
|
const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
|
||||||
if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.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.
|
// Debounce so a rapid pan-zoom only fires one fetch.
|
||||||
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
||||||
this.moveDebounce = setTimeout(() => this.fetchAndPaintCells(), 250)
|
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
|
// 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.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
|
||||||
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
|
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
|
||||||
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
||||||
|
if (this.viewportDebounce) clearTimeout(this.viewportDebounce)
|
||||||
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
|
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
|
||||||
if (this.visibilityHandler) {
|
if (this.visibilityHandler) {
|
||||||
document.removeEventListener("visibilitychange", 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) {
|
reconnected(this: WeatherMapHook) {
|
||||||
if (this.map) {
|
if (this.map) {
|
||||||
this.map.invalidateSize()
|
this.map.invalidateSize()
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,17 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
@layers MapLayers.all()
|
@layers MapLayers.all()
|
||||||
@default_layer MapLayers.default_id()
|
@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
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
_ =
|
_ =
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
|
: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())
|
valid_times = recent_valid_times(Weather.available_weather_valid_times())
|
||||||
initial_vt = pick_initial_valid_time(valid_times)
|
initial_vt = pick_initial_valid_time(valid_times)
|
||||||
|
|
||||||
|
{lat, lon, zoom} = parse_viewport_params(params)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
page_title: "Weather Map",
|
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_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
|
||||||
initial_selected_time: initial_vt && DateTime.to_iso8601(initial_vt),
|
initial_selected_time: initial_vt && DateTime.to_iso8601(initial_vt),
|
||||||
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
|
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,
|
grid_visible: false,
|
||||||
radar_visible: false
|
radar_visible: false
|
||||||
)}
|
)}
|
||||||
|
|
@ -48,7 +65,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
|
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
|
end
|
||||||
|
|
||||||
defp pick_layer(layer, current) when is_binary(layer) do
|
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) when is_binary(current), do: current
|
||||||
defp pick_layer(_layer, _current), do: @default_layer
|
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([]), do: nil
|
||||||
|
|
||||||
defp pick_initial_valid_time(valid_times) do
|
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)
|
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, cutoff) != :lt end)
|
||||||
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
|
@impl true
|
||||||
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
|
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
|
||||||
if MapLayers.valid_id?(layer_id) 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
|
else
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
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
|
def handle_event("toggle_grid", _params, socket) do
|
||||||
visible = !socket.assigns.grid_visible
|
visible = !socket.assigns.grid_visible
|
||||||
|
|
||||||
|
|
@ -222,6 +321,9 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
data-selected-layer={@selected_layer}
|
data-selected-layer={@selected_layer}
|
||||||
data-valid-times={@initial_valid_times_json}
|
data-valid-times={@initial_valid_times_json}
|
||||||
data-selected-time={@initial_selected_time}
|
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"
|
class="absolute inset-0 z-0"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,46 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|
||||||
|
|
||||||
render_hook(lv, "select_layer", %{"layer" => "pwat"})
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue