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(#{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) 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"""
Rover planning {band_label(@band)} · Forecast +{@forecast_hour}h
{@scoring_step}
<.candidate_detail_panel :if={@selected_candidate} detail={@selected_candidate} />
Good locations
{c.grid} {Float.round(c.score, 1)} dB
{km_to_mi(c.distance_km)} mi {c.bearing_compass} of home
{elev_ft(c.elev_m)}
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 attr :detail, :map, required: true defp candidate_detail_panel(assigns) do ~H"""
{@detail.grid} {km_to_mi(@detail.candidate.distance_km)} mi {@detail.candidate.bearing_compass} of home · {elev_ft( @detail.rover_elev_m || @detail.candidate.elev_m )}
No selected stations to summarize.
{link.callsign} {format_margin(link.margin_db)} · {km_to_mi(link.distance_km)} mi
Aim {round(link.bearing_deg)}° ({link.bearing}) · clearance {clearance_label(link.clearance_m)}
rover {elev_ft(link.rover_elev_m)} · max obstacle {elev_ft(link.max_obstacle_m)}
<.profile_svg :if={link.profile_svg} svg={link.profile_svg} callsign={link.callsign} />
""" end attr :svg, :map, required: true attr :callsign, :string, required: true defp profile_svg(assigns) do ~H"""
rover {elev_ft(@svg.start_elev_m)} {km_to_mi(@svg.total_km)} mi {@callsign} {elev_ft(@svg.end_elev_m)}
""" end end