feat(rover): per-station detail panel with elevation profile + 'Ideal locations' label

This commit is contained in:
Graham McIntire 2026-04-25 17:41:47 -05:00
parent 819bf2e27d
commit 4611926b8d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 270 additions and 9 deletions

View file

@ -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

View file

@ -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
>
</div>
<.candidate_detail_panel :if={@selected_candidate} detail={@selected_candidate} />
<div
:if={@top_candidates != []}
class="absolute bottom-0 inset-x-0 h-[110px] bg-base-100/90 backdrop-blur border-t border-base-300 z-10 overflow-x-auto"
class="absolute bottom-0 inset-x-0 bg-base-100/90 backdrop-blur border-t border-base-300 z-10"
>
<div class="carousel carousel-start gap-3 p-3 h-full">
<div class="px-3 pt-1 text-[10px] uppercase tracking-wider text-base-content/60 font-semibold">
Ideal locations
</div>
<div class="carousel carousel-start gap-3 px-3 pb-3 pt-1 h-[110px] overflow-x-auto">
<div
:for={c <- @top_candidates}
class="carousel-item w-[280px] card bg-base-200 shadow-sm cursor-pointer"
@ -789,7 +877,7 @@ defmodule MicrowavepropWeb.RoverLive do
</div>
</div>
<div class="absolute bottom-[122px] left-4 z-20 bg-base-100/90 rounded-box shadow p-2 text-xs flex flex-col gap-1">
<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>
@ -991,4 +1079,66 @@ defmodule MicrowavepropWeb.RoverLive do
</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 · {round(
@detail.candidate.drive_min
)} min drive · {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 {link.bearing}
</span>
</div>
<div class="text-[11px] opacity-70 mt-0.5">
rover {elev_ft(link.rover_elev_m)} · max obstacle {elev_ft(link.max_obstacle_m)} · clearance {clearance_label(
link.clearance_m
)}
</div>
<.profile_svg :if={link.profile_svg} svg={link.profile_svg} />
</div>
</div>
</div>
"""
end
attr :svg, :map, 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)" />
<polyline points={@svg.line} fill="none" stroke="#0ea5e9" stroke-width="1.2" />
</svg>
<div class="flex justify-between text-[10px] opacity-50 font-mono">
<span>{elev_ft(@svg.min_elev_m)}</span>
<span>{km_to_mi(@svg.total_km)} mi</span>
<span>{elev_ft(@svg.max_elev_m)}</span>
</div>
"""
end
end