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:
parent
a8e225a92c
commit
160439db31
5 changed files with 160 additions and 12 deletions
|
|
@ -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()
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue