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 @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. 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 @group_order ["Surface", "Upper Air", "Ducting"] defp group_layers(layers) do layers |> Enum.group_by(& &1.group) |> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end) end defp layer_description(layers, selected_id) do case Enum.find(layers, &(&1.id == selected_id)) do %{desc: desc} -> desc _ -> nil end end defp selected_layer_label(layers, selected_id) do case Enum.find(layers, &(&1.id == selected_id)) do %{label: label} -> label _ -> "" end end defp selected_layer_group_label(layers, selected_id) do case Enum.find(layers, &(&1.id == selected_id)) do %{group: group} -> group _ -> "" end end attr :bands, :list, required: true attr :selected, :any, default: nil defp cutoff_band_picker(assigns) do ~H"""
Filter to band
""" end @impl true def render(assigns) do ~H"""
<%!-- Map container --%>
<%!-- Full-page map --%>
<%!-- Bottom forecast timeline --%>
<%!-- Mobile-only floating controls --%>
NTMS
Weather Map
<%= if @valid_time do %> {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")} <% else %> No data available <% end %>
{@initial_utc_clock}
<%!-- Point detail panel --%> <%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<%!-- Desktop right sidebar --%>
""" end end