368 lines
13 KiB
Elixir
368 lines
13 KiB
Elixir
defmodule MicrowavepropWeb.WeatherMapLive do
|
||
@moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map."
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
alias Microwaveprop.Weather
|
||
alias Microwaveprop.Weather.MapLayers
|
||
alias MicrowavepropWeb.WeatherMapComponent
|
||
|
||
@layers MapLayers.all()
|
||
@default_layer MapLayers.default_id()
|
||
|
||
# Canonical amateur microwave bands for the duct-cutoff layer picker.
|
||
# Buttons render in this order; an "All" reset clears the filter.
|
||
@cutoff_bands [10, 24, 47, 76, 122, 241]
|
||
|
||
# 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
|
||
_ =
|
||
if connected?(socket) do
|
||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
||
end
|
||
|
||
# Default the timeline cursor to the valid_time closest to "now" so
|
||
# users land on current conditions, not a +11h forecast hour (which
|
||
# the underlying cache can advance to while the worker is walking
|
||
# f00→f18).
|
||
#
|
||
# Mount stays cheap — no grid materialisation. The dead render
|
||
# would otherwise spend ~5–10 s materialising 92k cells through
|
||
# `WeatherLayers.derive`, blocking the HTTP response and tripping
|
||
# the LiveView socket join timeout. The client now paints weather
|
||
# through a tile endpoint, so mount only needs the selected layer
|
||
# and valid_time metadata.
|
||
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",
|
||
layers: @layers,
|
||
selected_layer: @default_layer,
|
||
cutoff_bands: @cutoff_bands,
|
||
band_filter: nil,
|
||
valid_time: initial_vt,
|
||
valid_times: valid_times,
|
||
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
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_params(params, _uri, socket) do
|
||
selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
|
||
band = pick_band(params["band"], 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(:band_filter, band)
|
||
|> assign(:current_lat, lat)
|
||
|> assign(:current_lon, lon)
|
||
|> assign(:current_zoom, zoom)}
|
||
end
|
||
|
||
defp pick_layer(layer, current) when is_binary(layer) do
|
||
if MapLayers.valid_id?(layer), do: layer, else: pick_layer(nil, current)
|
||
end
|
||
|
||
defp pick_layer(_layer, current) when is_binary(current), do: current
|
||
defp pick_layer(_layer, _current), do: @default_layer
|
||
|
||
# Band filter only applies to the duct-cutoff layer; switching away
|
||
# from it drops the filter so a stale `?band=24` on the URL doesn't
|
||
# ghost-mask the next layer that lands on the same hook.
|
||
defp pick_band(band, "duct_cutoff_ghz") when is_binary(band) do
|
||
case Integer.parse(band) do
|
||
{n, ""} -> if n in @cutoff_bands, do: n
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
defp pick_band(_band, _layer), do: nil
|
||
|
||
# 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
|
||
|
||
@doc false
|
||
# Public for direct unit testing: anchoring an integration test on a
|
||
# truncated `utc_now()` would only assert the new behavior past minute
|
||
# 30 of the wall clock.
|
||
@spec pick_initial_valid_time([DateTime.t()], DateTime.t()) :: DateTime.t() | nil
|
||
def pick_initial_valid_time(valid_times, now \\ DateTime.utc_now())
|
||
def pick_initial_valid_time([], _now), do: nil
|
||
|
||
def pick_initial_valid_time(valid_times, now) do
|
||
# Prefer the most recent valid_time that is <= now (the "current
|
||
# hour" — what HRRR analysis just delivered and what the HRDPS chain
|
||
# seeds when fired this hour). Fall back to the closest absolute
|
||
# match only when nothing on disk is yet at-or-before now.
|
||
past_or_equal =
|
||
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, now) != :gt end)
|
||
|
||
case past_or_equal do
|
||
[] -> Enum.min_by(valid_times, fn vt -> abs(DateTime.diff(vt, now, :second)) end)
|
||
list -> Enum.max_by(list, &DateTime.to_unix/1)
|
||
end
|
||
end
|
||
|
||
# Drop valid_times older than one hour ago. The HRRR scalar cache can
|
||
# hold the previous run's forecast hours past their useful life;
|
||
# showing 3-hour-old "current conditions" on the map is more
|
||
# misleading than helpful.
|
||
defp recent_valid_times(valid_times) do
|
||
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||
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)
|
||
band = Keyword.get(overrides, :band, socket.assigns[:band_filter])
|
||
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.
|
||
base = [{"layer", layer}]
|
||
band_params = if layer == "duct_cutoff_ghz" and is_integer(band), do: [{"band", band}], else: []
|
||
|
||
query =
|
||
URI.encode_query(base ++ band_params ++ [{"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
|
||
# Drop a lingering band filter when switching to a non-cutoff
|
||
# layer — keeps the URL tidy and avoids ghost-mask state.
|
||
band = if layer_id == "duct_cutoff_ghz", do: socket.assigns[:band_filter]
|
||
{:noreply, push_patch(socket, to: build_path(socket, layer: layer_id, band: band))}
|
||
else
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
# Click the active band again to reset to "All" (continuous overview).
|
||
def handle_event("select_band_filter", %{"band" => band_str}, socket) do
|
||
band =
|
||
case Integer.parse(band_str || "") do
|
||
{n, ""} -> if n in @cutoff_bands, do: n
|
||
_ -> nil
|
||
end
|
||
|
||
next = if band == socket.assigns[:band_filter], do: nil, else: band
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:band_filter, next)
|
||
|> push_event("set_band_filter", %{band: next})
|
||
|
||
{:noreply, push_patch(socket, to: build_path(socket, band: next))}
|
||
end
|
||
|
||
def handle_event("clear_band_filter", _params, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:band_filter, nil)
|
||
|> push_event("set_band_filter", %{band: nil})
|
||
|
||
{:noreply, push_patch(socket, to: build_path(socket, band: nil))}
|
||
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
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:grid_visible, visible)
|
||
|> push_event("toggle_grid", %{visible: visible})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("toggle_radar", _params, socket) do
|
||
visible = !socket.assigns.radar_visible
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:radar_visible, visible)
|
||
|> push_event("toggle_radar", %{visible: visible})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("select_time", %{"time" => time_str}, socket) do
|
||
with {:ok, time, _} <- DateTime.from_iso8601(time_str),
|
||
true <- time in socket.assigns.valid_times do
|
||
socket =
|
||
socket
|
||
|> assign(:valid_time, time)
|
||
|> push_event("update_weather_overlay", %{selected: DateTime.to_iso8601(time)})
|
||
|
||
{:noreply, socket}
|
||
else
|
||
_ -> {:noreply, socket}
|
||
end
|
||
end
|
||
|
||
# `/map`'s PropagationMap hook pushes `map_bounds` on moveend/zoomend.
|
||
# When a user navigates /map → /weather, an in-flight bounds push from
|
||
# the old hook can land on the new WeatherMapLive (LV reuses sockets
|
||
# across live navigation). WeatherMapLive doesn't need bounds — the
|
||
# /weather/cells HTTP endpoint computes them client-side — so swallow
|
||
# the event instead of crashing the LV process.
|
||
def handle_event("map_bounds", _params, socket), do: {:noreply, socket}
|
||
|
||
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
|
||
detail =
|
||
if socket.assigns.valid_time do
|
||
Weather.weather_point_detail(lat, lon, socket.assigns.valid_time)
|
||
end
|
||
|
||
payload = detail || %{}
|
||
{:noreply, push_event(socket, "point_detail", payload)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:weather_updated, _valid_time}, socket) do
|
||
valid_times = recent_valid_times(Weather.available_weather_valid_times())
|
||
|
||
# Keep whatever the user has scrubbed to if it's still available,
|
||
# otherwise fall back to the latest on-disk time (GridCache-backed
|
||
# hot path). Never block mount/refresh on a ProfilesFile read for
|
||
# the happy path.
|
||
selected =
|
||
cond do
|
||
socket.assigns.valid_time in valid_times -> socket.assigns.valid_time
|
||
valid_times != [] -> List.last(valid_times)
|
||
true -> nil
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:valid_times, valid_times)
|
||
|> assign(:valid_time, selected)
|
||
|> push_event("update_weather_overlay", %{selected: selected && DateTime.to_iso8601(selected)})
|
||
|> push_event("update_timeline", %{
|
||
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
|
||
selected: selected && DateTime.to_iso8601(selected)
|
||
})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
# A new forecast hour has landed. Re-enumerate the available times
|
||
# so the timeline picks up the extra hour, but keep the currently
|
||
# selected time where it is.
|
||
def handle_info({:propagation_pipeline_progress, _progress}, socket) do
|
||
valid_times = recent_valid_times(Weather.available_weather_valid_times())
|
||
|
||
if valid_times == socket.assigns.valid_times do
|
||
{:noreply, socket}
|
||
else
|
||
socket =
|
||
socket
|
||
|> assign(:valid_times, valid_times)
|
||
|> push_event("update_timeline", %{
|
||
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
|
||
selected: socket.assigns.valid_time && DateTime.to_iso8601(socket.assigns.valid_time)
|
||
})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
assigns =
|
||
assign(assigns,
|
||
page_subtitle: "Weather Map",
|
||
data_source: nil,
|
||
cutoff_bands: @cutoff_bands,
|
||
show_cutoff_bands: true,
|
||
show_other_weather_link: false,
|
||
other_weather_path: "/weather-ca",
|
||
other_weather_label: "Canadian Weather Map"
|
||
)
|
||
|
||
WeatherMapComponent.render(assigns)
|
||
end
|
||
end
|