diff --git a/lib/microwaveprop/rover/candidate_detail.ex b/lib/microwaveprop/rover/candidate_detail.ex new file mode 100644 index 00000000..80507f9e --- /dev/null +++ b/lib/microwaveprop/rover/candidate_detail.ex @@ -0,0 +1,111 @@ +defmodule Microwaveprop.Rover.CandidateDetail do + @moduledoc """ + Server-side summary for a rover candidate cell: per-link distance, + predicted SNR margin, terrain clearance, and the elevation profile + along the great-circle path to each selected fixed station. + """ + + alias Microwaveprop.Radio.Maidenhead + alias Microwaveprop.Rover.DriveTime + alias Microwaveprop.Rover.LinkMargin + alias Microwaveprop.Terrain.Srtm + + @sample_count 48 + + @type station :: %{callsign: String.t(), lat: float(), lon: float()} + + @type link_summary :: %{ + callsign: String.t(), + distance_km: float(), + bearing: String.t(), + margin_db: float() | nil, + rover_elev_m: integer() | nil, + station_elev_m: integer() | nil, + max_obstacle_m: integer() | nil, + clearance_m: integer() | nil, + profile: [%{dist_km: float(), elev: integer()}] + } + + @spec summarize(map(), [station()], non_neg_integer(), DateTime.t(), atom()) :: %{ + grid: String.t(), + rover_elev_m: integer() | nil, + links: [link_summary()] + } + def summarize(candidate, stations, band_mhz, valid_time, mode) do + tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm") + rover_elev = lookup_elev(candidate.lat, candidate.lon, tiles_dir) + + links = + Enum.map(stations, fn s -> + profile = profile_for(candidate, s, tiles_dir) + + max_obstacle = + profile + |> middle_samples() + |> Enum.map(& &1.elev) + |> safe_max() + + clearance = + if is_integer(rover_elev) and is_integer(max_obstacle), + do: rover_elev - max_obstacle + + margin = + LinkMargin.link_margin_db( + band_mhz, + valid_time, + {candidate.lat, candidate.lon}, + {s.lat, s.lon}, + mode + ) + + %{ + callsign: s.callsign, + distance_km: DriveTime.haversine_km({candidate.lat, candidate.lon}, {s.lat, s.lon}), + bearing: DriveTime.bearing_compass({candidate.lat, candidate.lon}, {s.lat, s.lon}), + margin_db: margin, + rover_elev_m: rover_elev, + station_elev_m: profile |> List.last() |> Map.get(:elev), + max_obstacle_m: max_obstacle, + clearance_m: clearance, + profile: profile + } + end) + + %{ + grid: Maidenhead.from_latlon(candidate.lat, candidate.lon, 10), + rover_elev_m: rover_elev, + links: links + } + end + + defp profile_for(candidate, station, tiles_dir) do + case Srtm.fetch_elevation_profile( + candidate.lat, + candidate.lon, + station.lat, + station.lon, + tiles_dir, + @sample_count + ) do + {:ok, points} -> + Enum.map(points, fn pt -> %{dist_km: pt.dist_km, elev: pt.elev} end) + + _ -> + [] + end + end + + defp lookup_elev(lat, lon, tiles_dir) do + case Srtm.lookup(lat, lon, tiles_dir) do + {:ok, e} -> e + _ -> nil + end + end + + defp middle_samples([]), do: [] + defp middle_samples([_]), do: [] + defp middle_samples([_ | rest]), do: Enum.drop(rest, -1) + + defp safe_max([]), do: nil + defp safe_max(xs), do: Enum.max(xs) +end diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index 78588d0e..d01df206 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -15,6 +15,7 @@ defmodule MicrowavepropWeb.RoverLive do 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 @@ -60,6 +61,7 @@ defmodule MicrowavepropWeb.RoverLive do valid_times: valid_times, current_valid_time: current_valid_time, top_candidates: [], + selected_candidate: nil, station_form_error: nil, home_form_error: nil, scoring_loading: false, @@ -273,15 +275,30 @@ defmodule MicrowavepropWeb.RoverLive do 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})} + 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} + :error -> + {:noreply, socket} + end + + candidate -> + detail = build_candidate_detail(candidate, socket) + + {:noreply, + socket + |> assign(selected_candidate: detail) + |> push_event("focus_cell", %{lat: candidate.lat, lon: candidate.lon, zoom: 11})} end end + def handle_event("close_candidate_detail", _params, socket) do + {:noreply, assign(socket, selected_candidate: nil)} + end + def handle_event("rover_cell_detail", %{"lat" => lat, "lon" => lon}, socket) do {:noreply, push_cell_popup(socket, lat, lon)} end @@ -294,7 +311,7 @@ defmodule MicrowavepropWeb.RoverLive do {:noreply, socket - |> assign(top_candidates: cands, scoring_loading: false) + |> assign(top_candidates: cands, selected_candidate: nil, scoring_loading: false) |> apply_scoring_warnings(warnings) |> push_event("rover_results", %{ cells: cells, @@ -680,6 +697,62 @@ defmodule MicrowavepropWeb.RoverLive do } 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 profile_to_svg([]), do: nil + + defp profile_to_svg(points) do + elevs = Enum.map(points, & &1.elev) + min_e = Enum.min(elevs) + max_e = Enum.max(elevs) + span = max(max_e - min_e, 1) + 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" + + %{ + line: line, + fill: fill, + min_elev_m: min_e, + max_elev_m: max_e, + total_km: total_km + } + end + defp stations_for_compute(stations) do Enum.map(stations, fn s -> %{ @@ -716,6 +789,16 @@ defmodule MicrowavepropWeb.RoverLive do 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 @@ -757,11 +840,16 @@ defmodule MicrowavepropWeb.RoverLive do > + <.candidate_detail_panel :if={@selected_candidate} detail={@selected_candidate} /> +
-