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 socket = assign(socket, :initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC")) # LiveStash only persists the keys passed to `stash_assigns/2` # (selected_band + selected_time). On reconnect the recovered socket has # just those — the rest of the mount-time assigns (bands, bounds, the # initial score JSON payload, toggles, etc.) have to be rebuilt or the # first render crashes with KeyError on :initial_scores_json. {recovered_band, recovered_time, socket} = case LiveStash.recover_state(socket) do {:recovered, recovered} -> {recovered.assigns[:selected_band], recovered.assigns[:selected_time], recovered} _ -> {nil, nil, socket} end bands = BandConfig.all_bands() selected_band = recovered_band || @default_band valid_times = Propagation.available_valid_times(selected_band) selected_time = recovered_time || closest_to_now(valid_times) center = initial_center(session) bounds = bounds_around(center) initial_scores = Propagation.scores_at(selected_band, selected_time, bounds) # preload_forecast runs on the first map_bounds event instead of # here — mount only knows a hardcoded fallback bounding box, so # preloading now populates the forecast cache with scores for the # wrong viewport and forecast-hour clicks show a small square of # coverage instead of the full map. {:ok, assign(socket, page_title: "Propagation Prediction Map", bands: bands, selected_band: selected_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, radar_visible: false, antenna_height_ft: 33 )} 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) send(self(), :preload_forecast) 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 # Fast path: the client already has the preloaded hour's scores in its # local cache and only needs the server to remember which time is selected # (used later for point_detail and forecast state on reconnect). def handle_event("set_selected_time", %{"time" => time_str}, socket) do case DateTime.from_iso8601(time_str) do {:ok, time, _} -> {:noreply, assign(socket, :selected_time, time)} _ -> {: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("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("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) # Rain-scatter (NEXRAD cell overlay + scatter-dB readings in the detail # panel) is disabled for now — the live radar WMS toggle covers the # "is there rain?" question and the scatter-propagation feature needs # more work before it's useful. To re-enable: restore the :rain_scatter # payload key and the start_async/handle_async pair below. payload = if detail, do: Map.put(detail, :forecast, forecast_data), 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) # Refresh the forecast cache for the new viewport — the client cleared its # local cache in sendBounds() so any timeline scrub would otherwise miss. send(self(), :preload_forecast) 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(:preload_forecast, socket) do band = socket.assigns.selected_band bounds = socket.assigns.bounds selected = socket.assigns.selected_time hours = socket.assigns.valid_times |> Enum.reject(&(selected && DateTime.compare(&1, selected) == :eq)) |> Enum.map(fn t -> %{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)} end) {:noreply, push_event(socket, "preload_forecast", %{hours: hours})} end 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) send(self(), :preload_forecast) 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 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"""