feat(map): shareable URL params for view/band/time

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.
This commit is contained in:
Graham McIntire 2026-04-19 08:53:00 -05:00
parent a8e225a92c
commit 160439db31
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 160 additions and 12 deletions

View file

@ -1503,11 +1503,15 @@ export const PropagationMap: Record<string, unknown> & {
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()
})
},

View file

@ -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"

View file

@ -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}

View file

@ -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,

View file

@ -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