diff --git a/assets/js/rover_map_hook.ts b/assets/js/rover_map_hook.ts index 2925c11e..e92bce10 100644 --- a/assets/js/rover_map_hook.ts +++ b/assets/js/rover_map_hook.ts @@ -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 homeLat: number homeLon: number @@ -84,6 +85,7 @@ export const RoverMap: Partial = { 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 = { // 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 = { 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) { diff --git a/lib/microwaveprop/rover/candidate_detail.ex b/lib/microwaveprop/rover/candidate_detail.ex index 80507f9e..c084e179 100644 --- a/lib/microwaveprop/rover/candidate_detail.ex +++ b/lib/microwaveprop/rover/candidate_detail.ex @@ -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), diff --git a/lib/microwaveprop/rover/drive_time.ex b/lib/microwaveprop/rover/drive_time.ex index 7478b3c0..1b94ff82 100644 --- a/lib/microwaveprop/rover/drive_time.ex +++ b/lib/microwaveprop/rover/drive_time.ex @@ -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"} diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index d01df206..1defd26e 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -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
{link.callsign} - {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
-
- rover {elev_ft(link.rover_elev_m)} · max obstacle {elev_ft(link.max_obstacle_m)} · clearance {clearance_label( - link.clearance_m - )} +
+ Aim {round(link.bearing_deg)}° + ({link.bearing}) · clearance {clearance_label(link.clearance_m)} +
+
+ rover {elev_ft(link.rover_elev_m)} · max obstacle {elev_ft(link.max_obstacle_m)}
<.profile_svg :if={link.profile_svg} svg={link.profile_svg} />
@@ -1135,9 +1166,9 @@ defmodule MicrowavepropWeb.RoverLive do
- {elev_ft(@svg.min_elev_m)} + {elev_ft(@svg.start_elev_m)} {km_to_mi(@svg.total_km)} mi - {elev_ft(@svg.max_elev_m)} + {elev_ft(@svg.end_elev_m)}
""" end