defmodule MicrowavepropWeb.MapLive do @moduledoc false use MicrowavepropWeb, :live_view use LiveStash alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Terrain.Viewshed require Logger @default_band 10_000 # Default map center when no visitor geolocation is available — DFW (EM12). @default_center %{lat: 32.897, lon: -97.038} @default_zoom 7 @impl true def mount(_params, session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") end case LiveStash.recover_state(socket) do {:recovered, socket} -> {:ok, socket} _ -> bands = BandConfig.all_bands() valid_times = Propagation.available_valid_times(@default_band) selected_time = closest_to_now(valid_times) center = initial_center(session) bounds = bounds_around(center) initial_scores = Propagation.scores_at(@default_band, selected_time, bounds) {:ok, assign(socket, page_title: "Propagation Prediction Map", bands: bands, selected_band: @default_band, initial_scores_json: Jason.encode!(initial_scores), valid_times: valid_times, selected_time: selected_time, bounds: bounds, initial_center: center, initial_zoom: @default_zoom, grid_visible: false, antenna_height_ft: 33 )} end end # Prefer the visitor's Cloudflare geolocation when present; fall back to DFW. defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do %{lat: lat, lon: lon} end defp initial_center(_session), do: @default_center # 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. defp bounds_around(%{lat: lat, lon: lon}) do %{ "south" => lat - 3.4, "north" => lat + 3.4, "west" => lon - 4.5, "east" => lon + 4.5 } end @impl true def handle_event("select_band", %{"value" => band}, socket) do band = if is_binary(band), do: String.to_integer(band), else: band valid_times = Propagation.available_valid_times(band) selected_time = closest_to_now(valid_times) scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds) socket = socket |> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time) |> LiveStash.stash_assigns([:selected_band, :selected_time]) |> push_event("update_scores", %{scores: scores}) |> push_event("update_band_info", %{band_info: band_info(band)}) |> push_timeline() {:noreply, socket} end def handle_event("select_time", %{"time" => time_str}, socket) do case DateTime.from_iso8601(time_str) do {:ok, time, _} -> scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds) socket = socket |> assign(:selected_time, time) |> push_event("update_scores", %{scores: scores}) {:noreply, socket} _ -> {:noreply, socket} end end def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do height = case Integer.parse(value) do {h, _} when h >= 0 and h <= 200 -> h _ -> 8 end {:noreply, assign(socket, :antenna_height_ft, height)} end 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("point_detail", %{"lat" => lat, "lon" => lon}, socket) do band = socket.assigns.selected_band detail = Propagation.point_detail(band, lat, lon, socket.assigns.selected_time) forecast = Propagation.point_forecast(band, lat, lon) forecast_data = Enum.map(forecast, fn f -> %{time: DateTime.to_iso8601(f.valid_time), score: f.score} end) scatter = fetch_rain_scatter(lat, lon, band) payload = if detail, do: detail |> Map.put(:forecast, forecast_data) |> Map.put(:rain_scatter, scatter), else: %{} {:noreply, push_event(socket, "point_detail", payload)} end def handle_event("map_bounds", bounds, socket) do scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds) socket = socket |> assign(:bounds, bounds) |> push_event("update_scores", %{scores: scores}) {:noreply, socket} end def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do band_mhz = socket.assigns.selected_band band_config = BandConfig.get(band_mhz) freq_ghz = band_mhz / 1_000 ant_height_m = socket.assigns.antenna_height_ft * 0.3048 score = case Propagation.point_detail(band_mhz, lat, lon) do %{score: s} -> s _ -> 50 end max_range_km = score_range_km(score, band_config) socket = start_async(socket, :viewshed, fn -> Viewshed.compute(lat, lon, freq_ghz: freq_ghz, ant_height_m: ant_height_m, max_range_km: max_range_km, score: score ) end) {:noreply, socket} end @impl true def handle_info({:propagation_updated, _valid_times}, socket) do band = socket.assigns.selected_band valid_times = Propagation.available_valid_times(band) # Stay on the same selected time if still available, else pick earliest selected_time = if socket.assigns.selected_time in valid_times do socket.assigns.selected_time else closest_to_now(valid_times) end scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds) socket = socket |> assign(valid_times: valid_times, selected_time: selected_time) |> push_event("update_scores", %{scores: scores}) |> push_timeline() {:noreply, socket} end @impl true def handle_async(:viewshed, {:ok, result}, socket) do points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end) Logger.info("Viewshed complete: #{length(points)} boundary points") socket = push_event(socket, "viewshed_result", %{ origin: result.origin, points: points }) {:noreply, socket} end def handle_async(:viewshed, {:exit, reason}, socket) do Logger.warning("Viewshed computation failed: #{inspect(reason)}") {:noreply, socket} end defp push_timeline(socket) do times = Enum.map(socket.assigns.valid_times, fn t -> %{time: DateTime.to_iso8601(t), label: format_time_label(t)} end) selected = if socket.assigns.selected_time, do: DateTime.to_iso8601(socket.assigns.selected_time) push_event(socket, "update_timeline", %{times: times, selected: selected}) end defp format_time_label(dt) do # Show as "HH:MM" in UTC Calendar.strftime(dt, "%H:%M") end defp closest_to_now([]), do: nil defp closest_to_now(times) do now = DateTime.utc_now() Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end) end defp score_range_km(score, config) do cond do score >= 80 -> config.exceptional_range_km score >= 65 -> config.extended_range_km score >= 50 -> config.typical_range_km score >= 33 -> round(config.typical_range_km * 0.6) true -> round(config.typical_range_km * 0.3) end end defp fetch_rain_scatter(lat, lon, band_mhz) do alias Microwaveprop.Propagation.RainScatter alias Microwaveprop.Weather.NexradClient freq_ghz = band_mhz / 1000.0 case NexradClient.fetch_rain_cells(lat, lon) do {:ok, rain_cells} -> cells = RainScatter.find_scatter_cells(rain_cells, lat, lon, freq_ghz) classification = RainScatter.classify(cells) %{cells: cells, classification: to_string(classification)} {:error, _} -> %{cells: [], classification: "none"} end end defp band_info(band_mhz) do config = BandConfig.get(band_mhz) %{ band_label: config.label, typical_range_km: config.typical_range_km, extended_range_km: config.extended_range_km, exceptional_range_km: config.exceptional_range_km, humidity_effect: to_string(config.humidity_effect), freq_mhz: config.freq_mhz } end defp selected_label(bands, selected_band) do case Enum.find(bands, &(&1.freq_mhz == selected_band)) do nil -> "Select Band" band -> band.label end end @impl true def render(assigns) do ~H"""
<%!-- Map container --%>
<%!-- Full-page map --%>
%{time: DateTime.to_iso8601(t), label: format_time_label(t)} end) ) } data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""} data-center-lat={@initial_center.lat} data-center-lon={@initial_center.lon} data-zoom={@initial_zoom} class="absolute inset-0 z-0" >
<%!-- Mobile-only floating controls --%>
NTMS
Propagation Prediction Map
<%!-- Point detail floating panel --%> <%!-- Bottom timeline bar --%>
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<%!-- Desktop right sidebar --%>
""" end end