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()
|
this.forecastCache.clear()
|
||||||
// Pad bounds by ~0.5° so Leaflet edge tiles always have data to render
|
// Pad bounds by ~0.5° so Leaflet edge tiles always have data to render
|
||||||
const b = this.map.getBounds().pad(0.1)
|
const b = this.map.getBounds().pad(0.1)
|
||||||
|
const c = this.map.getCenter()
|
||||||
this.pushEvent("map_bounds", {
|
this.pushEvent("map_bounds", {
|
||||||
south: b.getSouth(),
|
south: b.getSouth(),
|
||||||
north: b.getNorth(),
|
north: b.getNorth(),
|
||||||
west: b.getWest(),
|
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
|
if age_minutes > @stale_after_minutes do
|
||||||
%{
|
%{
|
||||||
state: :stale,
|
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: [],
|
details: [],
|
||||||
last_update_at: last
|
last_update_at: last
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
%{
|
%{
|
||||||
state: :idle,
|
state: :idle,
|
||||||
label: "Up to date · #{format_age(age_minutes)} ago",
|
label: "Up to date · #{format_age_with_ago(age_minutes)}",
|
||||||
details: [],
|
details: [],
|
||||||
last_update_at: last
|
last_update_at: last
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
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(1), do: "1m"
|
||||||
defp format_age(n) when n < 60, do: "#{n}m"
|
defp format_age(n) when n < 60, do: "#{n}m"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
@pipeline_status_refresh_ms 15_000
|
@pipeline_status_refresh_ms 15_000
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, session, socket) do
|
def mount(params, session, socket) do
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
||||||
|
|
@ -57,10 +57,10 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
bands = BandConfig.all_bands()
|
bands = BandConfig.all_bands()
|
||||||
selected_band = recovered_band || @default_band
|
|
||||||
valid_times = Propagation.available_valid_times(selected_band)
|
%{band: selected_band, time: selected_time, center: center, zoom: zoom, valid_times: valid_times} =
|
||||||
selected_time = recovered_time || closest_to_now(valid_times)
|
resolve_view(params, session, recovered_band, recovered_time)
|
||||||
center = initial_center(session)
|
|
||||||
visitor = visitor_location(session)
|
visitor = visitor_location(session)
|
||||||
bounds = bounds_around(center)
|
bounds = bounds_around(center)
|
||||||
initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)
|
initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)
|
||||||
|
|
@ -83,7 +83,9 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
selected_time: selected_time,
|
selected_time: selected_time,
|
||||||
bounds: bounds,
|
bounds: bounds,
|
||||||
initial_center: center,
|
initial_center: center,
|
||||||
initial_zoom: @default_zoom,
|
initial_zoom: zoom,
|
||||||
|
current_center: center,
|
||||||
|
current_zoom: zoom,
|
||||||
outside_conus: outside_conus?(visitor),
|
outside_conus: outside_conus?(visitor),
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
radar_visible: false,
|
radar_visible: false,
|
||||||
|
|
@ -132,6 +134,18 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
lon < @conus_lon_min or lon > @conus_lon_max
|
lon < @conus_lon_min or lon > @conus_lon_max
|
||||||
end
|
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°)
|
# 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
|
# around a given center so the initial HRRR score query still returns a
|
||||||
# useful tile set for the visible area.
|
# useful tile set for the visible area.
|
||||||
|
|
@ -144,6 +158,14 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
}
|
}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("select_band", %{"value" => band}, socket) do
|
def handle_event("select_band", %{"value" => band}, socket) do
|
||||||
band = if is_binary(band), do: String.to_integer(band), else: band
|
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_scores", %{scores: scores})
|
||||||
|> push_event("update_band_info", %{band_info: band_info(band)})
|
|> push_event("update_band_info", %{band_info: band_info(band)})
|
||||||
|> push_timeline()
|
|> push_timeline()
|
||||||
|
|> patch_map_url()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -173,6 +196,7 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:selected_time, time)
|
|> assign(:selected_time, time)
|
||||||
|> push_event("update_scores", %{scores: scores})
|
|> push_event("update_scores", %{scores: scores})
|
||||||
|
|> patch_map_url()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
|
|
@ -186,8 +210,11 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
# (used later for point_detail and forecast state on reconnect).
|
# (used later for point_detail and forecast state on reconnect).
|
||||||
def handle_event("set_selected_time", %{"time" => time_str}, socket) do
|
def handle_event("set_selected_time", %{"time" => time_str}, socket) do
|
||||||
case DateTime.from_iso8601(time_str) do
|
case DateTime.from_iso8601(time_str) do
|
||||||
{:ok, time, _} -> {:noreply, assign(socket, :selected_time, time)}
|
{:ok, time, _} ->
|
||||||
_ -> {:noreply, socket}
|
{:noreply, socket |> assign(:selected_time, time) |> patch_map_url()}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -246,7 +273,9 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:bounds, bounds)
|
|> assign(:bounds, bounds)
|
||||||
|
|> maybe_assign_center_zoom(bounds)
|
||||||
|> push_event("update_scores", %{scores: scores})
|
|> push_event("update_scores", %{scores: scores})
|
||||||
|
|> patch_map_url(replace: true)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -414,6 +443,83 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
"#{hours}h ago"
|
"#{hours}h ago"
|
||||||
end
|
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())
|
defp closest_to_now(times), do: cursor_for_now(times, DateTime.utc_now())
|
||||||
|
|
||||||
@doc false
|
@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-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
|
||||||
|
data-selected-band={@selected_band}
|
||||||
data-center-lat={@initial_center.lat}
|
data-center-lat={@initial_center.lat}
|
||||||
data-center-lon={@initial_center.lon}
|
data-center-lon={@initial_center.lon}
|
||||||
data-zoom={@initial_zoom}
|
data-zoom={@initial_zoom}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,20 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
|
||||||
assert status.label =~ "Up to date"
|
assert status.label =~ "Up to date"
|
||||||
end
|
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
|
test "returns :stale when the newest ScoresFile is more than 120 minutes old" do
|
||||||
ScoresFile.write!(
|
ScoresFile.write!(
|
||||||
10_000,
|
10_000,
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,25 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
||||||
end
|
end
|
||||||
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
|
describe "cursor_for_now/2" do
|
||||||
alias MicrowavepropWeb.MapLive
|
alias MicrowavepropWeb.MapLive
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue