feat(rover): per-station detail panel with elevation profile + 'Ideal locations' label
This commit is contained in:
parent
819bf2e27d
commit
4611926b8d
2 changed files with 270 additions and 9 deletions
111
lib/microwaveprop/rover/candidate_detail.ex
Normal file
111
lib/microwaveprop/rover/candidate_detail.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue