diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index d394d3cc..42716e74 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -86,6 +86,7 @@ interface WeatherMapHook extends ViewHook { packKey: string | null inflightCellsAbort: AbortController | null moveDebounce: ReturnType | null + viewportDebounce: ReturnType | 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() diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index 59c99cae..39bbc9c8 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -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" > diff --git a/test/microwaveprop_web/live/weather_map_live_test.exs b/test/microwaveprop_web/live/weather_map_live_test.exs index 6d510ad2..0b17375b 100644 --- a/test/microwaveprop_web/live/weather_map_live_test.exs +++ b/test/microwaveprop_web/live/weather_map_live_test.exs @@ -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