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 import MicrowavepropWeb.LiveHelpers, only: [parse_int: 2] alias Microwaveprop.Accounts.User alias Microwaveprop.Buildings.Index, as: BuildingsIndex alias Microwaveprop.Geocoder alias Microwaveprop.Propagation alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo alias Microwaveprop.Rover alias Microwaveprop.Rover.CandidateDetail 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_distance_mi 50 @km_per_mi 1.609344 @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) 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_distance_mi: @default_max_distance_mi, drive_radius_km: drive_radius_km(@default_max_distance_mi), fixed_stations: fixed_stations, persisted?: persisted?, home: home, current_valid_time: current_valid_time, top_candidates: [], selected_candidate: nil, station_form_error: nil, home_form_error: nil, scoring_loading: false, scoring_step: nil, only_good_locations: 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_distance_mi), do: max_distance_mi * @km_per_mi # ── 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, 48) {:noreply, assign(socket, forecast_hour: hour)} end def handle_event("set_max_distance", %{"value" => v}, socket) do miles = v |> parse_int(@default_max_distance_mi) |> clamp(25, 250) radius_km = drive_radius_km(miles) {:noreply, socket |> assign(max_distance_mi: miles, drive_radius_km: radius_km) |> push_event("update_drive_radius", %{km: radius_km})} end def handle_event("toggle_only_good", _params, socket) do {:noreply, assign(socket, only_good_locations: not socket.assigns.only_good_locations)} 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() case resolve_station_grid(callsign, grid) do {:ok, resolved_grid} -> attrs = build_station_attrs(callsign, resolved_grid) case create_station(socket, attrs) do {:ok, stations} -> {:noreply, socket |> assign_stations(stations) |> assign(station_form_error: nil) |> sync_url()} {:error, msg} -> {:noreply, assign(socket, station_form_error: msg)} end {:error, msg} -> {:noreply, assign(socket, station_form_error: msg)} end end def handle_event("update_station_grid", %{"station_id" => id, "grid" => new_grid}, socket) do new_grid = String.trim(new_grid) cond do new_grid == "" -> {:noreply, socket} not Maidenhead.valid?(new_grid) -> {:noreply, assign(socket, station_form_error: "invalid grid: #{new_grid}")} true -> case mutate_station_grid(socket, id, new_grid) do {:ok, stations} -> {:noreply, socket |> assign_stations(stations) |> assign(station_form_error: nil) |> sync_url()} {:error, msg} -> {:noreply, assign(socket, station_form_error: msg)} end end end def handle_event("set_home_qth", params, socket) do input = params |> Map.get("home_grid", "") |> String.trim() case resolve_home_input(input) do {:ok, %{lat: lat, lon: lon, grid: grid}} -> case update_home(socket, %{"home_grid" => grid, "home_lat" => lat, "home_lon" => lon}) do {:ok, home} -> {:noreply, socket |> assign(home: home, home_form_error: nil) |> push_event("update_home", %{lat: home.lat, lon: home.lon})} {:error, msg} -> {:noreply, assign(socket, home_form_error: msg)} end {: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 find_candidate(socket.assigns.top_candidates, grid) do nil -> 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 candidate -> detail = build_candidate_detail(candidate, socket) paths = candidate_path_payload(detail, socket.assigns.fixed_stations) {:noreply, socket |> assign(selected_candidate: detail) |> push_event("focus_cell", %{lat: candidate.lat, lon: candidate.lon, zoom: 11}) |> push_event("candidate_paths", %{ candidate: %{lat: candidate.lat, lon: candidate.lon}, paths: paths }) |> push_event("candidate_buildings", %{ buildings: buildings_along_paths(candidate, paths) })} end end def handle_event("close_candidate_detail", _params, socket) do {:noreply, socket |> assign(selected_candidate: nil) |> push_event("candidate_paths", %{candidate: nil, paths: []}) |> push_event("candidate_buildings", %{buildings: []})} 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} = result}, socket) do warnings = Map.get(result, :warnings, []) {:noreply, socket |> assign( top_candidates: cands, selected_candidate: nil, scoring_loading: false, scoring_step: nil ) |> apply_scoring_warnings(warnings) |> push_event("candidate_paths", %{candidate: nil, paths: []}) |> 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, socket |> assign(top_candidates: [], scoring_loading: false, scoring_step: nil) |> put_flash(:error, "Calculate failed: #{inspect(reason)}")} end @impl true def handle_info({:rover_progress, label, n, total}, socket) do {:noreply, assign(socket, scoring_step: "#{n}/#{total} · #{label}")} end defp apply_scoring_warnings(socket, []), do: clear_flash(socket) defp apply_scoring_warnings(socket, warnings) do put_flash(socket, :info, Enum.join(warnings, " ")) end # When the user supplies just a callsign, look it up via QRZ (with the # Google Maps geocoder fallback already wired into CallsignClient) and # use the returned 10-char grid. Explicit user-entered grids win over # the lookup so an operator can override an old QRZ entry. defp resolve_station_grid(_call, grid) when is_binary(grid) and grid != "", do: {:ok, grid} defp resolve_station_grid("", _grid), do: {:error, "callsign required"} defp resolve_station_grid(call, _grid) do case CallsignClient.locate(call) do {:ok, %{gridsquare: g}} when is_binary(g) and byte_size(g) >= 4 -> {:ok, g} {:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) -> {:ok, Maidenhead.from_latlon(lat, lon, 10)} {:ok, _} -> {:error, "QRZ lookup for #{call} returned no grid or coords"} {:error, reason} -> {:error, "couldn't locate #{call}: #{inspect(reason)}"} end end # ── Mutation helpers ──────────────────────────────────────────────── defp handle_station_mutation(socket, id, fun) do case fun.(socket, id) do {:ok, stations} -> {:noreply, socket |> assign_stations(stations) |> sync_url()} {:error, reason} -> Logger.error("RoverLive station mutation failed: id=#{inspect(id)} reason=#{inspect(reason)}") {:noreply, socket} end end defp assign_stations(socket, stations) do socket |> assign(fixed_stations: stations) |> push_event("stations_updated", %{stations: stations_for_compute(stations)}) 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 mutate_station_grid(%{assigns: %{persisted?: true}} = socket, id, new_grid) do user = current_user(socket) case Rover.update_station(user, id, %{"grid" => new_grid, "lat" => nil, "lon" => nil}) do {:ok, _} -> {:ok, Rover.list_stations(user)} {:error, :not_found} -> {:error, "station not found"} {:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)} end end defp mutate_station_grid(socket, id, new_grid) do stations = Enum.map(socket.assigns.fixed_stations, fn s -> if station_id(s) == id, do: replace_anonymous_station_grid(s, new_grid), else: s end) {:ok, stations} end defp replace_anonymous_station_grid(station, new_grid) do case Maidenhead.to_latlon(new_grid) do {:ok, {lat, lon}} -> %{station | grid: new_grid, lat: lat, lon: lon} :error -> station end 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_lat" => lat, "home_lon" => lon} = attrs) when is_number(lat) and is_number(lon) do grid = Map.get(attrs, "home_grid") || home_label(lat * 1.0, lon * 1.0) {:ok, %{label: grid, grid: grid, lat: lat * 1.0, lon: lon * 1.0, elev_m: nil}} 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"} # Accepts grid (EM13qc), callsign (W5ABC), or address ("100 Main St, Dallas TX"). # Returns {:ok, %{lat, lon, grid}} or {:error, "..."}. defp resolve_home_input(""), do: {:error, "enter a grid, callsign, or address"} defp resolve_home_input(input) do cond do Maidenhead.valid?(input) -> resolve_home_grid(input) callsign?(input) -> resolve_home_callsign(input) true -> resolve_home_address(input) end end defp resolve_home_grid(grid) do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, grid: grid}} :error -> {:error, "invalid grid"} end end defp resolve_home_callsign(call) do case CallsignClient.locate(call) do {:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}} {:error, msg} -> {:error, "callsign: #{msg}"} end end defp resolve_home_address(addr) do case Geocoder.geocode(addr) do {:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}} {:error, msg} -> {:error, "geocode: #{msg}"} end end defp callsign?(input) when is_binary(input) do String.match?(input, ~r/^[A-Za-z0-9\/]{3,12}$/) and not Maidenhead.valid?(input) end 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(
{@error}
<.link href={~p"/users/log-in"} class="link link-primary">Sign in to save.
""" end attr :detail, :map, required: true defp candidate_detail_panel(assigns) do ~H"""