prop/lib/microwaveprop_web/live/rover_live.ex
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -05:00

1332 lines
44 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
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_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(<tr><td style="padding-right:8px">#{r.call}</td><td style="text-align:right">#{margin_str}</td></tr>)
end)
elev_str = if is_integer(elev_m), do: "#{elev_m} m", else: ""
"""
<div style="font-size:12px;min-width:200px">
<div style="font-weight:600;font-size:13px">#{grid}</div>
<div style="opacity:0.7">#{elev_str} · #{Float.round(drive_min, 0)} min · #{Float.round(dist_km, 0)} km</div>
<table style="margin-top:6px;border-collapse:collapse;width:100%">#{rows_html}</table>
<div style="margin-top:6px;display:flex;gap:8px">
<a href="https://maps.apple.com/?q=#{grid}&ll=#{Float.round(:erlang.element(1, grid |> Maidenhead.to_latlon() |> elem(1)), 4)}" target="_blank" rel="noopener" style="color:#3b82f6">Open in Maps</a>
</div>
</div>
"""
rescue
_ -> "<div style=\"font-size:12px\">Cell detail unavailable</div>"
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)
pid = self()
progress = fn label, n, total -> send(pid, {:rover_progress, label, n, total}) end
socket
|> assign(scoring_loading: true, scoring_step: "Starting…")
|> start_async(:scoring, fn -> Compute.run(args, progress: progress) end)
end
defp scoring_args(socket) do
base = %{
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_distance_km: socket.assigns.drive_radius_km,
min_elev_gain: 0
}
if socket.assigns.only_good_locations do
goods =
Rover.list_locations()
|> Enum.filter(&(&1.status == :good))
|> Enum.map(fn l -> %{lat: l.lat, lon: l.lon} end)
Map.put(base, :good_locations, goods)
else
base
end
end
defp find_candidate(candidates, grid) do
Enum.find(candidates, fn c -> c.grid == grid end)
end
defp build_candidate_detail(candidate, socket) do
selected = Enum.filter(socket.assigns.fixed_stations, & &1.selected)
summary =
CandidateDetail.summarize(
candidate,
selected,
socket.assigns.band,
socket.assigns.current_valid_time || DateTime.utc_now(),
socket.assigns.mode
)
Map.merge(summary, %{
candidate: candidate,
links: Enum.map(summary.links, &decorate_link_profile/1)
})
end
# Pre-render the elevation profile as an SVG path so the template
# doesn't have to do float math inline.
defp decorate_link_profile(link) do
Map.put(link, :profile_svg, profile_to_svg(link.profile))
end
defp buildings_along_paths(candidate, paths) do
paths
|> Enum.flat_map(fn p ->
sample_along({candidate.lat, candidate.lon}, {p.station_lat, p.station_lon})
end)
|> Enum.flat_map(fn {lat, lon} ->
BuildingsIndex.records_near(lat, lon, 150)
end)
|> Enum.uniq_by(fn r -> {r.centroid_lat, r.centroid_lon} end)
|> Enum.map(fn r ->
%{
lat: r.centroid_lat,
lon: r.centroid_lon,
radius_m: r.max_radius_m,
height_m: r.height_m
}
end)
end
defp sample_along({lat1, lon1}, {lat2, lon2}) do
n = 24
for i <- 1..n do
f = i / (n + 1)
{lat1 + f * (lat2 - lat1), lon1 + f * (lon2 - lon1)}
end
end
defp candidate_path_payload(detail, fixed_stations) do
by_callsign = Map.new(fixed_stations, fn s -> {s.callsign, s} end)
Enum.flat_map(detail.links, fn link ->
case Map.get(by_callsign, link.callsign) do
nil ->
[]
station ->
[
%{
callsign: link.callsign,
station_lat: station.lat,
station_lon: station.lon,
margin_db: link.margin_db
}
]
end
end)
end
defp profile_to_svg([]), do: nil
defp profile_to_svg(points) do
tops =
Enum.map(points, fn pt ->
pt.elev + round(max(Map.get(pt, :building_m, 0.0), Map.get(pt, :canopy_m, 0)))
end)
elevs = Enum.map(points, & &1.elev)
min_e = Enum.min(elevs)
max_e = max(Enum.max(elevs), Enum.max(tops))
span = max(max_e - min_e, 1)
first = List.first(points)
last = List.last(points)
total_km = if last && last.dist_km > 0, do: last.dist_km, else: 1.0
line =
Enum.map_join(points, " ", fn pt ->
x = pt.dist_km / total_km * 240.0
y = 60.0 - (pt.elev - min_e) / span * 50.0
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
end)
fill = "0,60 " <> line <> " 240,60"
has_buildings? = Enum.any?(points, fn pt -> Map.get(pt, :building_m, 0.0) > 0.5 end)
has_canopy? = Enum.any?(points, fn pt -> Map.get(pt, :canopy_m, 0) > 1 end)
terrain_back =
points
|> Enum.reverse()
|> Enum.map_join(" ", fn pt ->
x = pt.dist_km / total_km * 240.0
y = 60.0 - (pt.elev - min_e) / span * 50.0
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
end)
obstacle_fill = fn obstacle_key ->
obstacle_line =
Enum.map_join(points, " ", fn pt ->
x = pt.dist_km / total_km * 240.0
top = pt.elev + round(Map.get(pt, obstacle_key, 0))
y = 60.0 - (top - min_e) / span * 50.0
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
end)
obstacle_line <> " " <> terrain_back
end
canopy_fill = if has_canopy?, do: obstacle_fill.(:canopy_m)
building_fill = if has_buildings?, do: obstacle_fill.(:building_m)
los_y_start = Float.round(60.0 - (first.elev - min_e) / span * 50.0, 2)
los_y_end = Float.round(60.0 - (last.elev - min_e) / span * 50.0, 2)
%{
line: line,
fill: fill,
canopy_fill: canopy_fill,
building_fill: building_fill,
start_elev_m: first.elev,
end_elev_m: last.elev,
total_km: total_km,
los_y_start: los_y_start,
los_y_end: los_y_end
}
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 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: ""
defp km_to_mi(km) when is_number(km), do: round(km * 0.621371)
defp km_to_mi(_), do: 0
defp elev_ft(nil), do: ""
defp elev_ft(m) when is_number(m), do: "#{round(m * 3.28084)} ft"
defp format_margin(nil), do: "— dB"
defp format_margin(db) when is_number(db), do: :erlang.float_to_binary(db * 1.0, decimals: 1) <> " dB"
defp clearance_label(nil), do: ""
defp clearance_label(m) when is_number(m) do
sign = if m >= 0, do: "+", else: ""
"#{sign}#{round(m * 3.28084)} ft"
end
# ── Render ───────────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div class="flex flex-col w-screen h-screen overflow-hidden">
<header class="flex items-center justify-between h-11 px-4 bg-base-100 border-b border-base-300 shrink-0">
<div class="flex flex-col leading-tight">
<span class="font-semibold text-sm">Rover planning</span>
<span class="text-xs text-base-content/60">
{band_label(@band)} · Forecast +{@forecast_hour}h
</span>
</div>
<div class="flex items-center gap-3">
<span
:if={@scoring_loading and @scoring_step}
class="text-xs text-base-content/60 font-mono"
>
{@scoring_step}
</span>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm"
disabled={@scoring_loading}
>
<span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
{if @scoring_loading, do: "Calculating…", else: "Calculate"}
</button>
</div>
</header>
<div class="flex flex-1 min-h-0">
<div class="relative flex-1 min-w-0">
<div
id="rover-map"
phx-hook="RoverMap"
phx-update="ignore"
data-home-lat={@home.lat}
data-home-lon={@home.lon}
data-drive-radius-km={@drive_radius_km}
data-stations={Jason.encode!(stations_for_compute(@fixed_stations))}
class={[
"absolute inset-0 z-0 transition-opacity",
@scoring_loading && "opacity-60"
]}
>
</div>
<.candidate_detail_panel :if={@selected_candidate} detail={@selected_candidate} />
<div
:if={@top_candidates != []}
class="absolute bottom-0 inset-x-0 bg-base-100/90 backdrop-blur border-t border-base-300 z-10"
>
<div class="px-3 pt-1 text-[10px] uppercase tracking-wider text-base-content/60 font-semibold">
Good locations
</div>
<div
id="rover-candidates-strip"
phx-hook="HorizontalWheelScroll"
class="flex flex-row flex-nowrap gap-3 px-3 pb-3 pt-1 h-[110px] overflow-x-auto overflow-y-hidden overscroll-x-contain"
>
<div
:for={c <- @top_candidates}
class="shrink-0 w-[280px] card bg-base-200 shadow-sm cursor-pointer"
phx-click="select_candidate"
phx-value-grid={c.grid}
>
<div class="card-body p-3">
<div class="flex items-center justify-between">
<span class="font-mono font-semibold">{c.grid}</span>
<span
class="badge badge-sm font-mono"
style={"background-color: #{c.tier_color}; color: #000"}
>
{Float.round(c.score, 1)} dB
</span>
</div>
<div class="text-xs opacity-70">
{km_to_mi(c.distance_km)} mi {c.bearing_compass} of home
</div>
<div class="text-xs opacity-70">
{elev_ft(c.elev_m)}
</div>
</div>
</div>
</div>
</div>
<div class="absolute bottom-[140px] left-4 z-20 bg-base-100/90 rounded-box shadow p-2 text-xs flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #16a34a; opacity: 0.5"></span>
<span>Excellent ≥10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #eab308; opacity: 0.5"></span>
<span>Good 3..10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #f97316; opacity: 0.5"></span>
<span>Marginal 0..3 dB</span>
</div>
<div class="flex items-center gap-2 pt-1 border-t border-base-300/50">
<span class="w-4 border-t-2 border-dashed border-sky-500"></span>
<span>Drive radius</span>
</div>
</div>
</div>
<aside
id="rover-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-72 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-y-auto"
>
<.sidebar_section title="HOME">
<.home_form home={@home} error={@home_form_error} persisted?={@persisted?} />
</.sidebar_section>
<.sidebar_section title="FIXED STATIONS">
<div class="flex flex-col gap-0.5">
<div :for={s <- @fixed_stations} class="flex items-center gap-2 py-1">
<label class="cursor-pointer flex items-center gap-2 min-w-0 shrink-0">
<input
type="checkbox"
class="checkbox checkbox-xs"
checked={s.selected}
phx-click="toggle_station"
phx-value-id={station_id(s)}
/>
<span class="font-mono text-xs truncate">{s.callsign}</span>
</label>
<form
phx-submit="update_station_grid"
class="flex-1 min-w-0"
id={"station-grid-form-" <> station_id(s)}
>
<input type="hidden" name="station_id" value={station_id(s)} />
<input
type="text"
name="grid"
value={s.grid || ""}
placeholder="grid"
autocomplete="off"
spellcheck="false"
phx-debounce="blur"
title="Click to edit; press Enter or click away to save"
class="font-mono text-[11px] w-full bg-transparent border-0 border-b border-transparent focus:border-base-content/30 focus:bg-base-100/30 focus:outline-none px-1 py-0 opacity-60 focus:opacity-100"
/>
</form>
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100 shrink-0"
phx-click="delete_station"
phx-value-id={station_id(s)}
title="Remove"
>
×
</button>
</div>
</div>
<form phx-submit="add_station" class="flex gap-1 mt-2">
<input
type="text"
name="callsign"
placeholder="Call (grid auto-looked-up)"
class="input input-bordered input-xs flex-1"
autocomplete="off"
/>
<input
type="text"
name="grid"
placeholder="Grid"
class="input input-bordered input-xs w-20"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs btn-primary">+</button>
</form>
<p :if={@station_form_error} class="text-error text-[11px] mt-1">
{@station_form_error}
</p>
<p :if={!@persisted?} class="text-[11px] opacity-70 mt-2">
<.link href={~p"/users/log-in"} class="link link-primary">Sign in</.link>
to save your station list.
</p>
</.sidebar_section>
<.sidebar_section title="FORECAST HOUR">
<input
type="range"
min="0"
max="18"
step="1"
value={@forecast_hour}
phx-change="set_forecast_hour"
phx-debounce="200"
phx-hook="RoverSlider"
phx-update="ignore"
id="rover-forecast-slider"
data-label-id="rover-forecast-label"
data-format="forecast"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">
<span id="rover-forecast-label">+{@forecast_hour}h</span>
</div>
</.sidebar_section>
<.sidebar_section title="BAND">
<div class="join w-full">
<button
:for={b <- @bands}
type="button"
phx-click="select_band"
phx-value-band={b.value}
class={[
"btn btn-sm join-item flex-1",
@band == b.value && "btn-primary"
]}
>
{b.label |> String.replace(" GHz", "G")}
</button>
</div>
</.sidebar_section>
<.sidebar_section title="MAX DISTANCE">
<input
type="range"
min="25"
max="250"
step="25"
value={@max_distance_mi}
phx-change="set_max_distance"
phx-debounce="200"
phx-hook="RoverSlider"
phx-update="ignore"
id="rover-distance-slider"
data-label-id="rover-distance-label"
data-format="distance"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">
<span id="rover-distance-label">{@max_distance_mi} mi</span> from {@home.label}
</div>
</.sidebar_section>
<.sidebar_section title="CANDIDATES">
<label class="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={@only_good_locations}
phx-click="toggle_only_good"
class="checkbox checkbox-sm checkbox-primary mt-0.5"
/>
<span class="text-xs leading-tight">
Only use known good locations
<span class="block text-[10px] opacity-60 mt-0.5">
Restricts suggestions to spots tagged "good" in <a
href={~p"/rover-locations"}
class="link"
target="_blank"
>/rover-locations</a>.
</span>
</span>
</label>
</.sidebar_section>
<div class="mt-auto p-3 text-[11px] text-neutral-content/50 border-t border-base-300/50">
Tip: click any cell to see per-station predicted SNR.
</div>
</aside>
</div>
</div>
"""
end
attr :title, :string, required: true
slot :inner_block, required: true
defp sidebar_section(assigns) do
~H"""
<section class="px-3 py-3 border-t border-base-300/50 first:border-t-0">
<h3 class="text-[10px] uppercase tracking-wider text-neutral-content/60 mb-2">{@title}</h3>
{render_slot(@inner_block)}
</section>
"""
end
attr :home, :map, required: true
attr :error, :string, default: nil
attr :persisted?, :boolean, required: true
defp home_form(assigns) do
~H"""
<form phx-submit="set_home_qth" class="flex gap-1">
<input
type="text"
name="home_grid"
value={@home.grid || @home.label}
placeholder="Home grid"
class="input input-bordered input-xs flex-1 font-mono"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs">Set</button>
</form>
<p :if={@error} class="text-error text-[11px] mt-1">{@error}</p>
<p :if={!@persisted?} class="text-[11px] opacity-70 mt-1">
<.link href={~p"/users/log-in"} class="link link-primary">Sign in</.link> to save.
</p>
"""
end
attr :detail, :map, required: true
defp candidate_detail_panel(assigns) do
~H"""
<div class="absolute right-4 bottom-[140px] z-20 w-[340px] max-h-[60vh] bg-base-100/95 backdrop-blur rounded-box shadow-lg border border-base-300 overflow-hidden flex flex-col">
<div class="flex items-center justify-between px-3 py-2 border-b border-base-300">
<div class="flex flex-col leading-tight min-w-0">
<span class="font-mono font-semibold text-sm truncate">{@detail.grid}</span>
<span class="text-[11px] opacity-60">
{km_to_mi(@detail.candidate.distance_km)} mi {@detail.candidate.bearing_compass} of home · {elev_ft(
@detail.rover_elev_m || @detail.candidate.elev_m
)}
</span>
</div>
<button
type="button"
class="btn btn-ghost btn-xs btn-square"
phx-click="close_candidate_detail"
>
×
</button>
</div>
<div class="flex flex-col divide-y divide-base-300/60 overflow-y-auto">
<div :if={@detail.links == []} class="px-3 py-3 text-xs opacity-70">
No selected stations to summarize.
</div>
<div :for={link <- @detail.links} class="px-3 py-2">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-sm font-semibold">{link.callsign}</span>
<span class="font-mono text-xs">
{format_margin(link.margin_db)} · {km_to_mi(link.distance_km)} mi
</span>
</div>
<div class="text-[11px] opacity-80 mt-0.5">
Aim <span class="font-mono">{round(link.bearing_deg)}°</span>
({link.bearing}) · clearance {clearance_label(link.clearance_m)}
</div>
<div class="text-[11px] opacity-60">
rover {elev_ft(link.rover_elev_m)} · max obstacle {elev_ft(link.max_obstacle_m)}
</div>
<.profile_svg :if={link.profile_svg} svg={link.profile_svg} callsign={link.callsign} />
</div>
</div>
</div>
"""
end
attr :svg, :map, required: true
attr :callsign, :string, required: true
defp profile_svg(assigns) do
~H"""
<svg viewBox="0 0 240 64" class="w-full h-12 mt-1" preserveAspectRatio="none">
<polygon points={@svg.fill} fill="rgb(14 165 233 / 0.25)" />
<polygon
:if={@svg.canopy_fill}
points={@svg.canopy_fill}
fill="rgb(34 197 94 / 0.45)"
stroke="#16a34a"
stroke-width="0.4"
/>
<polygon
:if={@svg.building_fill}
points={@svg.building_fill}
fill="rgb(220 38 38 / 0.55)"
stroke="#dc2626"
stroke-width="0.6"
/>
<polyline points={@svg.line} fill="none" stroke="#0ea5e9" stroke-width="1.2" />
<line
x1="0"
y1={@svg.los_y_start}
x2="240"
y2={@svg.los_y_end}
stroke="#f59e0b"
stroke-width="1"
stroke-dasharray="3 2"
opacity="0.85"
/>
</svg>
<div class="flex justify-between text-[10px] opacity-60 font-mono gap-2">
<span>rover {elev_ft(@svg.start_elev_m)}</span>
<span class="opacity-70">{km_to_mi(@svg.total_km)} mi</span>
<span>{@callsign} {elev_ft(@svg.end_elev_m)}</span>
</div>
"""
end
end