From 160439db312debb2dc14c69bf38c8a3a919080c0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 19 Apr 2026 08:53:00 -0500 Subject: [PATCH] feat(map): shareable URL params for view/band/time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main map page now reads band, selected time, and map center/zoom from URL params and updates the URL as the user pans, zooms, picks a band, or scrubs the timeline. Pan/zoom use push_patch replace-mode so browser history isn't spammed, while band and time changes push fresh entries. Also fix the 'Up to date · just now ago' pipeline chip — 'just now' already reads as an instant so the trailing 'ago' is redundant. Any older duration keeps it. --- assets/js/propagation_map_hook.ts | 6 +- .../propagation/pipeline_status.ex | 10 +- lib/microwaveprop_web/live/map_live.ex | 123 ++++++++++++++++-- .../propagation/pipeline_status_test.exs | 14 ++ test/microwaveprop_web/live/map_live_test.exs | 19 +++ 5 files changed, 160 insertions(+), 12 deletions(-) diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index 2442a779..dcc9b347 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -1503,11 +1503,15 @@ export const PropagationMap: Record & { this.forecastCache.clear() // Pad bounds by ~0.5° so Leaflet edge tiles always have data to render const b = this.map.getBounds().pad(0.1) + const c = this.map.getCenter() this.pushEvent("map_bounds", { south: b.getSouth(), north: b.getNorth(), west: b.getWest(), - east: b.getEast() + east: b.getEast(), + center_lat: c.lat, + center_lon: c.lng, + zoom: this.map.getZoom() }) }, diff --git a/lib/microwaveprop/propagation/pipeline_status.ex b/lib/microwaveprop/propagation/pipeline_status.ex index 0b4a4f27..8053ea16 100644 --- a/lib/microwaveprop/propagation/pipeline_status.ex +++ b/lib/microwaveprop/propagation/pipeline_status.ex @@ -131,21 +131,25 @@ defmodule Microwaveprop.Propagation.PipelineStatus do if age_minutes > @stale_after_minutes do %{ state: :stale, - label: "Propagation data stale · last update #{format_age(age_minutes)} ago", + label: "Propagation data stale · last update #{format_age_with_ago(age_minutes)}", details: [], last_update_at: last } else %{ state: :idle, - label: "Up to date · #{format_age(age_minutes)} ago", + label: "Up to date · #{format_age_with_ago(age_minutes)}", details: [], last_update_at: last } end end - defp format_age(0), do: "just now" + # "just now" reads fine on its own; anything older needs "ago" for + # the duration to make sense. + defp format_age_with_ago(0), do: "just now" + defp format_age_with_ago(n), do: "#{format_age(n)} ago" + defp format_age(1), do: "1m" defp format_age(n) when n < 60, do: "#{n}m" diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 9c8b548a..21de57d7 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -29,7 +29,7 @@ defmodule MicrowavepropWeb.MapLive do @pipeline_status_refresh_ms 15_000 @impl true - def mount(_params, session, socket) do + def mount(params, session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline") @@ -57,10 +57,10 @@ defmodule MicrowavepropWeb.MapLive do end bands = BandConfig.all_bands() - selected_band = recovered_band || @default_band - valid_times = Propagation.available_valid_times(selected_band) - selected_time = recovered_time || closest_to_now(valid_times) - center = initial_center(session) + + %{band: selected_band, time: selected_time, center: center, zoom: zoom, valid_times: valid_times} = + resolve_view(params, session, recovered_band, recovered_time) + visitor = visitor_location(session) bounds = bounds_around(center) initial_scores = Propagation.scores_at(selected_band, selected_time, bounds) @@ -83,7 +83,9 @@ defmodule MicrowavepropWeb.MapLive do selected_time: selected_time, bounds: bounds, initial_center: center, - initial_zoom: @default_zoom, + initial_zoom: zoom, + current_center: center, + current_zoom: zoom, outside_conus: outside_conus?(visitor), grid_visible: false, radar_visible: false, @@ -132,6 +134,18 @@ defmodule MicrowavepropWeb.MapLive do lon < @conus_lon_min or lon > @conus_lon_max end + # Resolve the initial band/time/center/zoom from URL params, falling + # back to LiveStash recovery, then session geolocation and defaults. + defp resolve_view(params, session, recovered_band, recovered_time) do + band = parse_band_param(params["band"]) || recovered_band || @default_band + valid_times = Propagation.available_valid_times(band) + time = parse_time_param(params["t"]) || recovered_time || closest_to_now(valid_times) + center = parse_center_param(params["lat"], params["lon"]) || initial_center(session) + zoom = parse_zoom_param(params["zoom"]) || @default_zoom + + %{band: band, time: time, center: center, zoom: zoom, valid_times: valid_times} + end + # Build a bounding box roughly matching the hardcoded default (~3.4° × 9°) # around a given center so the initial HRRR score query still returns a # useful tile set for the visible area. @@ -144,6 +158,14 @@ defmodule MicrowavepropWeb.MapLive do } end + @impl true + def handle_params(_params, _url, socket) do + # URL params are applied in mount/3; subsequent push_patch calls + # hit this callback with the same params that our own push_patch + # just wrote, so there's nothing further to reconcile here. + {:noreply, socket} + end + @impl true def handle_event("select_band", %{"value" => band}, socket) do band = if is_binary(band), do: String.to_integer(band), else: band @@ -160,6 +182,7 @@ defmodule MicrowavepropWeb.MapLive do |> push_event("update_scores", %{scores: scores}) |> push_event("update_band_info", %{band_info: band_info(band)}) |> push_timeline() + |> patch_map_url() {:noreply, socket} end @@ -173,6 +196,7 @@ defmodule MicrowavepropWeb.MapLive do socket |> assign(:selected_time, time) |> push_event("update_scores", %{scores: scores}) + |> patch_map_url() {:noreply, socket} @@ -186,8 +210,11 @@ defmodule MicrowavepropWeb.MapLive do # (used later for point_detail and forecast state on reconnect). def handle_event("set_selected_time", %{"time" => time_str}, socket) do case DateTime.from_iso8601(time_str) do - {:ok, time, _} -> {:noreply, assign(socket, :selected_time, time)} - _ -> {:noreply, socket} + {:ok, time, _} -> + {:noreply, socket |> assign(:selected_time, time) |> patch_map_url()} + + _ -> + {:noreply, socket} end end @@ -246,7 +273,9 @@ defmodule MicrowavepropWeb.MapLive do socket = socket |> assign(:bounds, bounds) + |> maybe_assign_center_zoom(bounds) |> push_event("update_scores", %{scores: scores}) + |> patch_map_url(replace: true) {:noreply, socket} end @@ -414,6 +443,83 @@ defmodule MicrowavepropWeb.MapLive do "#{hours}h ago" end + # --- URL param plumbing ----------------------------------------------- + + # The JS hook piggybacks map center + zoom onto the bounds event + # so the URL can carry a shareable view. Old payloads (no center) + # should still work — fall back to keeping whatever we had. + defp maybe_assign_center_zoom(socket, %{"center_lat" => lat, "center_lon" => lon, "zoom" => zoom}) + when is_number(lat) and is_number(lon) and is_number(zoom) do + assign(socket, current_center: %{lat: lat, lon: lon}, current_zoom: trunc(zoom)) + end + + defp maybe_assign_center_zoom(socket, _), do: socket + + defp patch_map_url(socket, opts \\ []) do + push_patch(socket, to: map_url(socket.assigns), replace: Keyword.get(opts, :replace, false)) + end + + defp map_url(assigns) do + params = + %{} + |> Map.put("band", assigns.selected_band) + |> put_if(assigns.selected_time, "t", fn dt -> DateTime.to_iso8601(dt) end) + |> put_if(assigns[:current_center], "lat", fn c -> Float.round(c.lat, 3) end) + |> put_if(assigns[:current_center], "lon", fn c -> Float.round(c.lon, 3) end) + |> put_if(assigns[:current_zoom], "zoom", fn z -> trunc(z) end) + + "/map?" <> URI.encode_query(params) + end + + defp put_if(map, nil, _key, _fun), do: map + defp put_if(map, value, key, fun), do: Map.put(map, key, fun.(value)) + + @doc false + def parse_band_param(nil), do: nil + + def parse_band_param(str) when is_binary(str) do + case Integer.parse(str) do + {n, ""} -> if BandConfig.get(n), do: n + _ -> nil + end + end + + @doc false + def parse_time_param(nil), do: nil + + def parse_time_param(str) when is_binary(str) do + case DateTime.from_iso8601(str) do + {:ok, dt, _} -> DateTime.truncate(dt, :second) + _ -> nil + end + end + + @doc false + def parse_center_param(lat_s, lon_s) when is_binary(lat_s) and is_binary(lon_s) do + with {lat, ""} <- Float.parse(lat_s), + {lon, ""} <- Float.parse(lon_s), + true <- lat >= -90.0 and lat <= 90.0, + true <- lon >= -180.0 and lon <= 180.0 do + %{lat: lat, lon: lon} + else + _ -> nil + end + end + + def parse_center_param(_, _), do: nil + + @doc false + def parse_zoom_param(nil), do: nil + + def parse_zoom_param(str) when is_binary(str) do + case Integer.parse(str) do + {n, ""} when n >= 1 and n <= 18 -> n + _ -> nil + end + end + + # --- end URL param plumbing ------------------------------------------- + defp closest_to_now(times), do: cursor_for_now(times, DateTime.utc_now()) @doc false @@ -566,6 +672,7 @@ defmodule MicrowavepropWeb.MapLive do ) } data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""} + data-selected-band={@selected_band} data-center-lat={@initial_center.lat} data-center-lon={@initial_center.lon} data-zoom={@initial_zoom} diff --git a/test/microwaveprop/propagation/pipeline_status_test.exs b/test/microwaveprop/propagation/pipeline_status_test.exs index 1db9c7ab..330bc41c 100644 --- a/test/microwaveprop/propagation/pipeline_status_test.exs +++ b/test/microwaveprop/propagation/pipeline_status_test.exs @@ -114,6 +114,20 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do assert status.label =~ "Up to date" end + test "drops the 'ago' suffix when the data is brand new" do + ScoresFile.write!( + 10_000, + DateTime.utc_now(), + [%{lat: 25.0, lon: -125.0, score: 50}] + ) + + status = PipelineStatus.current() + + assert status.state == :idle + assert status.label =~ "Up to date · just now" + refute status.label =~ "just now ago" + end + test "returns :stale when the newest ScoresFile is more than 120 minutes old" do ScoresFile.write!( 10_000, diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index 330c5b57..dac01da7 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -128,6 +128,25 @@ defmodule MicrowavepropWeb.MapLiveTest do end end + describe "URL params" do + test "band URL param selects the requested band on mount", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/map?band=24000") + assert html =~ ~s(data-selected-band="24000") + end + + test "lat/lon/zoom URL params override the default map center/zoom", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/map?lat=40.0&lon=-100.0&zoom=5") + assert html =~ ~s(data-center-lat="40.0") + assert html =~ ~s(data-center-lon="-100.0") + assert html =~ ~s(data-zoom="5") + end + + test "invalid band falls back to the default", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/map?band=notanumber") + assert html =~ ~s(data-selected-band="10000") + end + end + describe "cursor_for_now/2" do alias MicrowavepropWeb.MapLive