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
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue