feat(rover): show aim heading + path lines for each fixed station

- Detail panel now reads as an aiming card: "Aim 220° (SW) · clearance +12 ft"
  with the precise bearing in degrees, plus rover/obstacle elev on a second line
- Elevation profile labels swapped from min/max-of-profile to start (rover) and
  end (station) elevations, since their positions in the chart now match
- Map draws a dashed indigo polyline from the candidate to each selected
  station when a candidate is opened; cleared when the panel closes
This commit is contained in:
Graham McIntire 2026-04-25 18:10:15 -05:00
parent 4bfcff4e32
commit 0d2cfb68f1
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 77 additions and 17 deletions

View file

@ -25,6 +25,7 @@ interface RoverMapHook extends ViewHook {
driveCircle: L.Circle | null
qualityLayer: L.GridLayer | null
selectedCandidateMarker: L.CircleMarker | null
candidatePathLayer: L.LayerGroup
cellLookup: Map<string, Cell>
homeLat: number
homeLon: number
@ -84,6 +85,7 @@ export const RoverMap: Partial<RoverMapHook> = {
this.driveCircle = null
this.qualityLayer = null
this.selectedCandidateMarker = null
this.candidatePathLayer = L.layerGroup()
this.cellLookup = new Map()
this.topoLayer = null
@ -156,6 +158,8 @@ export const RoverMap: Partial<RoverMapHook> = {
// user starts zoomed in at the configured max-distance level.
map.fitBounds(this.driveCircle.getBounds(), { padding: [20, 20] })
this.candidatePathLayer.addTo(map)
L.control.scale({ metric: false, imperial: true, position: "bottomleft" }).addTo(map)
// Render initial stations from data attribute
@ -238,6 +242,23 @@ export const RoverMap: Partial<RoverMapHook> = {
this.map.flyTo([lat, lon], zoom, { duration: 0.6 })
})
this.handleEvent("candidate_paths", (
{ candidate, paths }: {
candidate: { lat: number; lon: number } | null
paths: { callsign: string; station_lat: number; station_lon: number; margin_db: number | null }[]
}
) => {
this.candidatePathLayer.clearLayers()
if (!candidate || !paths || paths.length === 0) return
for (const p of paths) {
L.polyline(
[[candidate.lat, candidate.lon], [p.station_lat, p.station_lon]],
{ color: "#6366f1", weight: 2, dashArray: "6 4", opacity: 0.85 }
).addTo(this.candidatePathLayer)
}
})
},
reconnected(this: RoverMapHook) {

View file

@ -18,6 +18,7 @@ defmodule Microwaveprop.Rover.CandidateDetail do
callsign: String.t(),
distance_km: float(),
bearing: String.t(),
bearing_deg: float(),
margin_db: float() | nil,
rover_elev_m: integer() | nil,
station_elev_m: integer() | nil,
@ -58,10 +59,14 @@ defmodule Microwaveprop.Rover.CandidateDetail do
mode
)
from = {candidate.lat, candidate.lon}
to = {s.lat, s.lon}
%{
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}),
distance_km: DriveTime.haversine_km(from, to),
bearing: DriveTime.bearing_compass(from, to),
bearing_deg: DriveTime.bearing_deg(from, to),
margin_db: margin,
rover_elev_m: rover_elev,
station_elev_m: profile |> List.last() |> Map.get(:elev),

View file

@ -25,7 +25,12 @@ defmodule Microwaveprop.Rover.DriveTime do
def drive_min(dist_km) when is_number(dist_km), do: dist_km / @avg_speed_kmh * 60.0
@spec bearing_compass({float(), float()}, {float(), float()}) :: String.t()
def bearing_compass({lat1, lon1}, {lat2, lon2}) do
def bearing_compass(from, to) do
from |> bearing_deg(to) |> compass_for()
end
@spec bearing_deg({float(), float()}, {float(), float()}) :: float()
def bearing_deg({lat1, lon1}, {lat2, lon2}) do
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
dlon = deg_to_rad(lon2 - lon1)
@ -33,9 +38,7 @@ defmodule Microwaveprop.Rover.DriveTime do
y = :math.sin(dlon) * :math.cos(rlat2)
x = :math.cos(rlat1) * :math.sin(rlat2) - :math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon)
bearing = y |> :math.atan2(x) |> rad_to_deg() |> normalize_deg()
compass_for(bearing)
y |> :math.atan2(x) |> rad_to_deg() |> normalize_deg()
end
@compass_points {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}

View file

@ -291,12 +291,19 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply,
socket
|> assign(selected_candidate: detail)
|> push_event("focus_cell", %{lat: candidate.lat, lon: candidate.lon, zoom: 11})}
|> push_event("focus_cell", %{lat: candidate.lat, lon: candidate.lon, zoom: 11})
|> push_event("candidate_paths", %{
candidate: %{lat: candidate.lat, lon: candidate.lon},
paths: candidate_path_payload(detail, socket.assigns.fixed_stations)
})}
end
end
def handle_event("close_candidate_detail", _params, socket) do
{:noreply, assign(socket, selected_candidate: nil)}
{:noreply,
socket
|> assign(selected_candidate: nil)
|> push_event("candidate_paths", %{candidate: nil, paths: []})}
end
def handle_event("rover_cell_detail", %{"lat" => lat, "lon" => lon}, socket) do
@ -725,6 +732,27 @@ defmodule MicrowavepropWeb.RoverLive do
Map.put(link, :profile_svg, profile_to_svg(link.profile))
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
@ -732,6 +760,7 @@ defmodule MicrowavepropWeb.RoverLive do
min_e = Enum.min(elevs)
max_e = Enum.max(elevs)
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
@ -747,8 +776,8 @@ defmodule MicrowavepropWeb.RoverLive do
%{
line: line,
fill: fill,
min_elev_m: min_e,
max_elev_m: max_e,
start_elev_m: first.elev,
end_elev_m: last.elev,
total_km: total_km
}
end
@ -1111,13 +1140,15 @@ defmodule MicrowavepropWeb.RoverLive do
<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}
{format_margin(link.margin_db)} · {km_to_mi(link.distance_km)} mi
</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 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} />
</div>
@ -1135,9 +1166,9 @@ defmodule MicrowavepropWeb.RoverLive do
<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>{elev_ft(@svg.start_elev_m)}</span>
<span>{km_to_mi(@svg.total_km)} mi</span>
<span>{elev_ft(@svg.max_elev_m)}</span>
<span>{elev_ft(@svg.end_elev_m)}</span>
</div>
"""
end