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:
parent
4bfcff4e32
commit
0d2cfb68f1
4 changed files with 77 additions and 17 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue