prop/lib/microwaveprop_web/live/weather_map_live.ex

368 lines
13 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ~510 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