defmodule MicrowavepropWeb.RoverLive do @moduledoc """ Rover-planning page: enter the fixed stations you want to work, pick a band and forecast hour, and the map paints a smoothed propagation quality heatmap with the top 5 ranked drive-to candidates pinned in the bottom strip. """ use MicrowavepropWeb, :live_view alias Microwaveprop.Accounts.User alias Microwaveprop.Propagation alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo alias Microwaveprop.Rover alias Microwaveprop.Rover.Compute alias Microwaveprop.Rover.DriveTime alias Microwaveprop.Rover.LinkMargin alias Microwaveprop.Terrain.Srtm require Logger @bands [ %{label: "10 GHz", value: 10_000}, %{label: "24 GHz", value: 24_000}, %{label: "47 GHz", value: 47_000} ] @default_band 10_000 @default_mode :ssb @default_forecast_hour 0 @default_max_drive_min 120 @avg_speed_kmh 65.0 # NTMS-area anonymous home: EM13 centroid. @anonymous_home %{label: "EM13", grid: "EM13", lat: 33.5, lon: -97.0, elev_m: nil} @impl true def mount(_params, _session, socket) do {fixed_stations, persisted?} = load_stations(socket) home = home_for(socket) valid_times = Propagation.available_valid_times(@default_band) current_valid_time = Propagation.latest_valid_time(@default_band) {:ok, assign(socket, page_title: "Rover Planner", bands: @bands, band: @default_band, mode: @default_mode, forecast_hour: @default_forecast_hour, max_drive_min: @default_max_drive_min, drive_radius_km: drive_radius_km(@default_max_drive_min), fixed_stations: fixed_stations, persisted?: persisted?, home: home, valid_times: valid_times, current_valid_time: current_valid_time, top_candidates: [], station_form_error: nil, home_form_error: nil, scoring_loading: false, url_loaded?: false )} end @impl true def handle_params(params, _uri, socket) do if socket.assigns.url_loaded? do {:noreply, socket} else socket = socket |> apply_url_stations(params) |> assign(url_loaded?: true) {:noreply, socket} end end defp apply_url_stations(%{assigns: %{persisted?: true}} = socket, _params), do: socket defp apply_url_stations(socket, %{"stations" => stations_str}) when is_binary(stations_str) do stations = parse_url_stations(stations_str) if stations == [], do: socket, else: assign(socket, fixed_stations: stations) end defp apply_url_stations(socket, _params), do: socket defp parse_url_stations(str) do str |> String.split(",", trim: true) |> Enum.with_index() |> Enum.flat_map(fn {entry, idx} -> entry |> parse_url_station_entry(idx) |> List.wrap() end) end defp parse_url_station_entry("-" <> rest, idx), do: parse_url_station_pair(rest, idx, false) defp parse_url_station_entry(entry, idx), do: parse_url_station_pair(entry, idx, true) defp parse_url_station_pair(entry, idx, selected?) do with [call, grid] <- String.split(entry, ":", parts: 2), call = String.upcase(String.trim(call)), grid = String.trim(grid), true <- call != "", {:ok, {lat, lon}} <- Maidenhead.to_latlon(grid) do %{ id: "url-#{idx}", callsign: call, grid: grid, lat: lat, lon: lon, elevation_m: nil, selected: selected?, position: idx } else _ -> nil end end # ── Lifecycle helpers ──────────────────────────────────────────────── defp load_stations(socket) do case current_user(socket) do %User{} = user -> case Rover.list_stations(user) do [] -> {seed_default_stations(user), true} stations -> {stations, true} end _ -> {Rover.default_stations(), false} end end defp seed_default_stations(user) do Enum.each(Rover.default_stations(), fn attrs -> Rover.create_station(user, Map.delete(attrs, :position)) end) Rover.list_stations(user) end defp home_for(socket) do case current_user(socket) do %User{home_lat: lat, home_lon: lon, home_grid: grid, home_elevation_m: elev} when is_float(lat) and is_float(lon) -> %{label: grid || home_label(lat, lon), grid: grid, lat: lat, lon: lon, elev_m: elev} _ -> @anonymous_home end end defp current_user(socket) do case socket.assigns[:current_scope] do %{user: %User{} = user} -> user _ -> nil end end defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 10) defp drive_radius_km(max_drive_min), do: max_drive_min * @avg_speed_kmh / 60.0 # ── Events ──────────────────────────────────────────────────────────── @impl true def handle_event("select_band", %{"band" => band_str}, socket) do band = parse_int(band_str, @default_band) {:noreply, assign(socket, band: band)} end def handle_event("set_forecast_hour", %{"value" => v}, socket) do hour = v |> parse_int(@default_forecast_hour) |> clamp(0, 18) {:noreply, assign(socket, forecast_hour: hour)} end def handle_event("set_max_drive_time", %{"value" => v}, socket) do minutes = v |> parse_int(@default_max_drive_min) |> clamp(30, 240) radius_km = drive_radius_km(minutes) {:noreply, socket |> assign(max_drive_min: minutes, drive_radius_km: radius_km) |> push_event("update_drive_radius", %{km: radius_km})} end def handle_event("toggle_station", %{"id" => id}, socket) do handle_station_mutation(socket, id, &toggle_station/2) end def handle_event("delete_station", %{"id" => id}, socket) do handle_station_mutation(socket, id, &delete_station/2) end def handle_event("add_station", params, socket) do callsign = params |> Map.get("callsign", "") |> String.trim() grid = params |> Map.get("grid", "") |> String.trim() attrs = build_station_attrs(callsign, grid) case create_station(socket, attrs) do {:ok, stations} -> {:noreply, socket |> assign(fixed_stations: stations, station_form_error: nil) |> sync_url()} {:error, msg} -> {:noreply, assign(socket, station_form_error: msg)} end end def handle_event("set_home_qth", params, socket) do grid = params |> Map.get("home_grid", "") |> String.trim() attrs = %{"home_grid" => grid} case update_home(socket, attrs) do {:ok, home} -> {:noreply, assign(socket, home: home, home_form_error: nil)} {:error, msg} -> {:noreply, assign(socket, home_form_error: msg)} end end def handle_event("calculate", _params, socket) do {:noreply, start_scoring(socket)} end def handle_event("select_candidate", %{"grid" => grid}, socket) do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> {:noreply, push_event(socket, "focus_cell", %{lat: lat, lon: lon, zoom: 11})} :error -> {:noreply, socket} end end def handle_event("rover_cell_detail", %{"lat" => lat, "lon" => lon}, socket) do {:noreply, push_cell_popup(socket, lat, lon)} end # ── Async ──────────────────────────────────────────────────────────── @impl true def handle_async(:scoring, {:ok, %{cells: cells, top_candidates: cands}}, socket) do {:noreply, socket |> assign(top_candidates: cands, scoring_loading: false) |> push_event("rover_results", %{ cells: cells, drive_radius_km: socket.assigns.drive_radius_km })} end def handle_async(:scoring, {:exit, reason}, socket) do Logger.error("rover scoring crashed: #{inspect(reason)}") {:noreply, assign(socket, top_candidates: [], scoring_loading: false)} end # ── Mutation helpers ──────────────────────────────────────────────── defp handle_station_mutation(socket, id, fun) do case fun.(socket, id) do {:ok, stations} -> {:noreply, socket |> assign(fixed_stations: stations) |> sync_url()} {:error, _} -> {:noreply, socket} end end defp sync_url(socket) do push_patch(socket, to: rover_path(socket), replace: true) end defp rover_path(socket) do case build_url_stations(socket.assigns.fixed_stations) do "" -> ~p"/rover" str -> ~p"/rover?#{[stations: str]}" end end defp build_url_stations(stations) do stations |> Enum.map(&encode_url_station/1) |> Enum.reject(&is_nil/1) |> Enum.join(",") end defp encode_url_station(%{callsign: call, grid: grid, selected: sel}) when is_binary(grid) and grid != "" do prefix = if sel, do: "", else: "-" "#{prefix}#{call}:#{grid}" end defp encode_url_station(_), do: nil defp toggle_station(%{assigns: %{persisted?: true}} = socket, id) do user = current_user(socket) with {:ok, _} <- Rover.toggle_selected(user, id) do {:ok, Rover.list_stations(user)} end end defp toggle_station(socket, id) do stations = Enum.map(socket.assigns.fixed_stations, fn s -> if station_id(s) == id, do: Map.put(s, :selected, !s.selected), else: s end) {:ok, stations} end defp delete_station(%{assigns: %{persisted?: true}} = socket, id) do user = current_user(socket) with {:ok, _} <- Rover.delete_station(user, id) do {:ok, Rover.list_stations(user)} end end defp delete_station(socket, id) do stations = Enum.reject(socket.assigns.fixed_stations, &(station_id(&1) == id)) {:ok, stations} end defp create_station(%{assigns: %{persisted?: true}} = socket, attrs) do user = current_user(socket) case Rover.create_station(user, attrs) do {:ok, _station} -> {:ok, Rover.list_stations(user)} {:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)} end end defp create_station(socket, attrs) do case build_anonymous_station(attrs, length(socket.assigns.fixed_stations)) do {:ok, station} -> {:ok, socket.assigns.fixed_stations ++ [station]} {:error, msg} -> {:error, msg} end end defp build_anonymous_station(%{"callsign" => call} = attrs, position) when is_binary(call) and call != "" do grid = attrs["grid"] with {:ok, {lat, lon}} <- resolve_anonymous_latlon(grid, attrs["lat"], attrs["lon"]) do {:ok, %{ id: "anon-#{:erlang.unique_integer([:positive])}", callsign: call |> String.trim() |> String.upcase(), grid: if(grid && grid != "", do: grid), lat: lat, lon: lon, elevation_m: nil, selected: true, position: position }} end end defp build_anonymous_station(_attrs, _position), do: {:error, "callsign required"} defp resolve_anonymous_latlon(grid, _lat, _lon) when is_binary(grid) and grid != "" do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> {:ok, {lat, lon}} :error -> {:error, "invalid grid"} end end defp resolve_anonymous_latlon(_grid, lat, lon) when is_number(lat) and is_number(lon) do {:ok, {lat * 1.0, lon * 1.0}} end defp resolve_anonymous_latlon(_grid, _lat, _lon), do: {:error, "grid or lat/lon required"} defp build_station_attrs(callsign, grid) do %{} |> put_if_present("callsign", callsign) |> put_if_present("grid", grid) end defp put_if_present(map, _key, ""), do: map defp put_if_present(map, key, val), do: Map.put(map, key, val) defp update_home(%{assigns: %{persisted?: true}} = socket, attrs) do user = current_user(socket) case user |> User.change_home_qth(attrs) |> Repo.update() do {:ok, %User{} = updated} -> {:ok, %{ label: updated.home_grid || home_label(updated.home_lat, updated.home_lon), grid: updated.home_grid, lat: updated.home_lat, lon: updated.home_lon, elev_m: updated.home_elevation_m }} {:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)} end end defp update_home(_socket, %{"home_grid" => grid}) when is_binary(grid) and grid != "" do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> {:ok, %{label: grid, grid: grid, lat: lat, lon: lon, elev_m: nil}} :error -> {:error, "invalid grid"} end end defp update_home(_socket, _attrs), do: {:error, "grid required"} defp changeset_first_error(%Ecto.Changeset{errors: [{field, {msg, _}} | _]}) do "#{field}: #{msg}" end defp changeset_first_error(_), do: "could not save" # ── Cell popup ─────────────────────────────────────────────────────── defp push_cell_popup(socket, lat, lon) do %{ band: band, mode: mode, current_valid_time: valid_time, fixed_stations: stations, home: home } = socket.assigns grid = Maidenhead.from_latlon(lat, lon, 10) elev_m = lookup_elev(lat, lon) dist_km = haversine_km(home, %{lat: lat, lon: lon}) drive_min = dist_km / @avg_speed_kmh * 60.0 rows = stations |> Enum.filter(& &1.selected) |> Enum.map(fn s -> link_row(s, band, valid_time, lat, lon, mode) end) html = render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows) push_event(socket, "show_cell_popup", %{lat: lat, lon: lon, html: html}) end defp link_row(station, band, valid_time, lat, lon, mode) do case LinkMargin.link_margin_db( band, valid_time, {lat, lon}, {station.lat, station.lon}, mode ) do nil -> %{call: station.callsign, predicted_db: nil, margin: nil} margin -> %{call: station.callsign, predicted_db: margin, margin: margin} end end defp render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows) do rows_html = Enum.map_join(rows, "", fn r -> margin_str = case r.margin do nil -> "—" m -> :erlang.float_to_binary(m, decimals: 1) <> " dB" end ~s(#{r.call}#{margin_str}) end) elev_str = if is_integer(elev_m), do: "#{elev_m} m", else: "—" """
#{grid}
#{elev_str} · #{Float.round(drive_min, 0)} min · #{Float.round(dist_km, 0)} km
#{rows_html}
Open in Maps
""" rescue _ -> "
Cell detail unavailable
" end defp lookup_elev(lat, lon) do tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm") case Srtm.lookup(lat, lon, tiles_dir) do {:ok, e} -> e _ -> nil end end defp haversine_km(%{lat: lat1, lon: lon1}, %{lat: lat2, lon: lon2}) do DriveTime.haversine_km({lat1, lon1}, {lat2, lon2}) end # ── Calculate ─────────────────────────────────────────────────────── defp start_scoring(socket) do args = scoring_args(socket) socket |> assign(scoring_loading: true) |> start_async(:scoring, fn -> Compute.run(args) end) end defp scoring_args(socket) do %{ home: socket.assigns.home, stations: stations_for_compute(socket.assigns.fixed_stations), band_mhz: socket.assigns.band, valid_time: socket.assigns.current_valid_time || DateTime.utc_now(), mode: socket.assigns.mode, max_drive_min: socket.assigns.max_drive_min, min_elev_gain: 0 } end defp stations_for_compute(stations) do Enum.map(stations, fn s -> %{ callsign: s.callsign, lat: s.lat, lon: s.lon, selected: !!s.selected } end) end # ── Helpers ───────────────────────────────────────────────────────── defp parse_int(v, default) do case Integer.parse("#{v}") do {n, _} -> n :error -> default end end defp clamp(v, lo, hi), do: v |> max(lo) |> min(hi) defp band_label(band) do Enum.find_value(@bands, "#{band} MHz", fn b -> if b.value == band, do: b.label end) end defp station_id(%{id: id}) when not is_nil(id), do: to_string(id) defp station_id(%{callsign: call}) when is_binary(call), do: "call:" <> call defp station_id(_), do: "" # ── Render ─────────────────────────────────────────────────────────── @impl true def render(assigns) do ~H"""
Rover planning {band_label(@band)} · Forecast +{@forecast_hour}h
Excellent ≥10 dB
Good 3..10 dB
Marginal 0..3 dB
Drive radius
""" end attr :title, :string, required: true slot :inner_block, required: true defp sidebar_section(assigns) do ~H"""

{@title}

{render_slot(@inner_block)}
""" end attr :home, :map, required: true attr :error, :string, default: nil attr :persisted?, :boolean, required: true defp home_form(assigns) do ~H"""

{@error}

<.link href={~p"/users/log-in"} class="link link-primary">Sign in to save.

""" end end