prop/lib/microwaveprop_web/live/rover_live.ex
Graham McIntire 7ffa20bee7 Add /rover planner for microwave contest rovers
Interactive map-based tool for planning rover operating positions.
Rovers enter stationary stations they want to work, then click the
map to evaluate candidate operating grids.

Features:
- Add stationary stations by callsign or grid square
- Click map to add operating positions (snaps to Maidenhead grid)
- Async SRTM terrain analysis: each stop computes terrain profile
  to every station, shows CLEAR/BLOCKED verdict + diffraction loss
- Terrain lines on map: green=workable, red=blocked, dashed=marginal
- Propagation score and 18-hour forecast sparkline per stop
- Route summary: driving distance, unique grid-station pairs
- Band selector affects range limits and propagation scores
- Numbered waypoint markers with score-colored backgrounds

New files:
- Geo module (shared haversine/bearing, used by PathLive too)
- RoverLive with fullscreen map + sidebar
- RoverMap JS hook with grid overlay, station/stop markers, route line
- Nav links in header and map control panel
2026-04-07 14:17:50 -05:00

443 lines
14 KiB
Elixir

defmodule MicrowavepropWeb.RoverLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Geo
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
@band_options [
{"10 GHz", "10000"},
{"24 GHz", "24000"},
{"47 GHz", "47000"},
{"76 GHz", "75000"},
{"122 GHz", "122000"},
{"241 GHz", "241000"}
]
@impl true
def mount(_params, _session, socket) do
{:ok,
assign(socket,
page_title: "Rover Planner",
band_options: @band_options,
band: 10_000,
stations: [],
station_input: "",
stops: [],
selected_stop: nil,
stop_detail: nil,
resolving: false,
computing_stop: false
)}
end
# ── Station Management ──
@impl true
def handle_event("add_station", %{"callsign" => input}, socket) do
input = String.trim(input)
if input == "" do
{:noreply, socket}
else
send(self(), {:resolve_station, input})
{:noreply, assign(socket, resolving: true, station_input: "")}
end
end
def handle_event("remove_station", %{"index" => idx_str}, socket) do
idx = String.to_integer(idx_str)
stations = List.delete_at(socket.assigns.stations, idx)
{:noreply, socket |> assign(stations: stations) |> push_stations()}
end
def handle_event("select_band", %{"band" => band_str}, socket) do
{:noreply, socket |> assign(band: String.to_integer(band_str)) |> push_stations()}
end
# ── Stop Management (from JS map clicks) ──
def handle_event("add_stop", %{"lat" => lat, "lon" => lon}, socket) do
grid = Geo.latlon_to_grid4(lat, lon)
{clat, clon} = Geo.maidenhead_center(grid) || {lat, lon}
stop = %{
grid: grid,
lat: clat,
lon: clon,
score: nil,
forecast: [],
reachable: []
}
# Get propagation score
score_detail = Propagation.point_detail(socket.assigns.band, clat, clon)
forecast = Propagation.point_forecast(socket.assigns.band, clat, clon)
stop = %{stop | score: score_detail && score_detail.score, forecast: forecast}
stops = socket.assigns.stops ++ [stop]
# Async compute terrain to each station
send(self(), {:compute_stop_terrain, length(stops) - 1})
{:noreply, socket |> assign(stops: stops, computing_stop: true) |> push_stops()}
end
def handle_event("remove_stop", %{"index" => idx_str}, socket) do
idx = String.to_integer(idx_str)
stops = List.delete_at(socket.assigns.stops, idx)
selected =
if socket.assigns.selected_stop == idx, do: nil, else: socket.assigns.selected_stop
{:noreply, socket |> assign(stops: stops, selected_stop: selected, stop_detail: nil) |> push_stops()}
end
def handle_event("select_stop", %{"index" => idx_str}, socket) do
idx = String.to_integer(idx_str)
stop = Enum.at(socket.assigns.stops, idx)
{:noreply, assign(socket, selected_stop: idx, stop_detail: stop)}
end
# ── Async Handlers ──
@impl true
def handle_info({:resolve_station, input}, socket) do
result = resolve_station(input)
case result do
{:ok, station} ->
stations = socket.assigns.stations ++ [station]
{:noreply, socket |> assign(stations: stations, resolving: false) |> push_stations()}
{:error, _reason} ->
{:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")}
end
end
def handle_info({:compute_stop_terrain, stop_idx}, socket) do
stop = Enum.at(socket.assigns.stops, stop_idx)
if is_nil(stop) do
{:noreply, assign(socket, computing_stop: false)}
else
stations = socket.assigns.stations
band_config = BandConfig.get(socket.assigns.band) || BandConfig.get(10_000)
freq_ghz = socket.assigns.band / 1000
reachable =
stations
|> Task.async_stream(
fn station ->
dist = Geo.haversine_km(stop.lat, stop.lon, station.lat, station.lon)
bearing = Geo.bearing_deg(stop.lat, stop.lon, station.lat, station.lon)
terrain =
case ElevationClient.fetch_elevation_profile(stop.lat, stop.lon, station.lat, station.lon, 64,
download: true
) do
{:ok, profile} ->
TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
{:error, _} ->
nil
end
in_range = dist <= (band_config.extended_range_km || 500)
%{
label: station.label,
lat: station.lat,
lon: station.lon,
dist_km: Float.round(dist, 1),
bearing: bearing,
verdict: terrain && terrain.verdict,
diffraction_db: terrain && Float.round(terrain.diffraction_db, 1),
in_range: in_range,
workable: in_range and terrain != nil and terrain.verdict != "BLOCKED"
}
end,
max_concurrency: 4,
timeout: 15_000
)
|> Enum.map(fn
{:ok, r} -> r
_ -> nil
end)
|> Enum.reject(&is_nil/1)
stop = %{stop | reachable: reachable}
stops = List.replace_at(socket.assigns.stops, stop_idx, stop)
{:noreply,
socket
|> assign(stops: stops, computing_stop: false)
|> push_stops()
|> push_event("stop_terrain", %{index: stop_idx, reachable: reachable})}
end
end
# ── Helpers ──
defp resolve_station(input) do
input = String.trim(input)
if Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) and String.length(input) >= 4 do
case Maidenhead.to_latlon(input) do
{:ok, {lat, lon}} ->
{:ok, %{label: String.upcase(input), lat: lat, lon: lon, type: :grid}}
:error ->
{:error, "invalid grid"}
end
else
case CallsignClient.locate(input) do
{:ok, info} ->
{:ok, %{label: String.upcase(info.callsign), lat: info.lat, lon: info.lon, type: :callsign}}
{:error, reason} ->
{:error, reason}
end
end
end
defp push_stations(socket) do
data =
Enum.map(socket.assigns.stations, fn s ->
%{label: s.label, lat: s.lat, lon: s.lon}
end)
push_event(socket, "stations_updated", %{stations: data})
end
defp push_stops(socket) do
data =
Enum.with_index(socket.assigns.stops, fn stop, idx ->
%{
index: idx,
grid: stop.grid,
lat: stop.lat,
lon: stop.lon,
score: stop.score,
reachable_count: Enum.count(stop.reachable, & &1.workable)
}
end)
push_event(socket, "stops_updated", %{stops: data})
end
defp total_driving_km(stops) do
stops
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [a, b] -> Geo.haversine_km(a.lat, a.lon, b.lat, b.lon) end)
|> Enum.sum()
|> Float.round(1)
end
defp total_workable(stops) do
stops
|> Enum.flat_map(fn stop ->
stop.reachable
|> Enum.filter(& &1.workable)
|> Enum.map(fn r -> {stop.grid, r.label} end)
end)
|> Enum.uniq()
|> length()
end
defp tier_color(score) when score >= 80, do: "#00ffa3"
defp tier_color(score) when score >= 65, do: "#7dffd4"
defp tier_color(score) when score >= 50, do: "#ffe566"
defp tier_color(score) when score >= 33, do: "#ff9044"
defp tier_color(_), do: "#ff4f4f"
defp verdict_badge("CLEAR"), do: "badge badge-success badge-sm"
defp verdict_badge("FRESNEL_MINOR"), do: "badge badge-warning badge-sm"
defp verdict_badge("FRESNEL_PARTIAL"), do: "badge badge-warning badge-sm"
defp verdict_badge("BLOCKED"), do: "badge badge-error badge-sm"
defp verdict_badge(_), do: "badge badge-sm"
# ── Render ──
@impl true
def render(assigns) do
~H"""
<div class="relative w-screen h-screen overflow-hidden">
<%!-- Map --%>
<div
id="rover-map"
phx-hook="RoverMap"
phx-update="ignore"
data-band={@band}
class="absolute inset-0 z-0"
>
</div>
<%!-- Sidebar --%>
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
<div class="font-bold text-sm">Rover Planner</div>
<%!-- Band selector --%>
<select
phx-change="select_band"
name="band"
class="select select-bordered select-sm w-full"
>
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<%!-- Add station --%>
<form phx-submit="add_station" class="flex gap-1">
<input
type="text"
name="callsign"
value={@station_input}
placeholder="Add station (call or grid)"
class="input input-bordered input-sm flex-1"
/>
<button type="submit" class="btn btn-sm btn-primary" disabled={@resolving}>
<%= if @resolving do %>
<span class="loading loading-spinner loading-xs"></span>
<% else %>
Add
<% end %>
</button>
</form>
<%!-- Station list --%>
<%= if @stations != [] do %>
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div>
<div class="flex flex-wrap gap-1">
<%= for {station, idx} <- Enum.with_index(@stations) do %>
<div class="badge badge-outline gap-1">
{station.label}
<button
type="button"
phx-click="remove_station"
phx-value-index={idx}
class="cursor-pointer opacity-50 hover:opacity-100"
>
x
</button>
</div>
<% end %>
</div>
<% end %>
<%!-- Instructions --%>
<%= if @stations != [] and @stops == [] do %>
<div class="text-xs opacity-50 text-center py-2">
Click the map to add operating positions
</div>
<% end %>
<%!-- Route stops --%>
<%= if @stops != [] do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">
Route ({length(@stops)} stops, {total_driving_km(@stops)} km driving)
</div>
<%= for {stop, idx} <- Enum.with_index(@stops) do %>
<div
class={[
"bg-base-200 rounded-lg p-2 text-xs cursor-pointer hover:bg-base-300 transition-colors",
@selected_stop == idx && "ring-2 ring-primary"
]}
phx-click="select_stop"
phx-value-index={idx}
>
<div class="flex justify-between items-center">
<div class="font-bold">
<span class="badge badge-sm badge-primary mr-1">{idx + 1}</span>
{stop.grid}
</div>
<div class="flex gap-1 items-center">
<%= if stop.score do %>
<span class="font-mono font-bold" style={"color: #{tier_color(stop.score)}"}>
{stop.score}
</span>
<% end %>
<button
type="button"
phx-click="remove_stop"
phx-value-index={idx}
class="btn btn-ghost btn-xs opacity-50"
>
x
</button>
</div>
</div>
<%= if stop.reachable != [] do %>
<div class="mt-1 opacity-70">
{Enum.count(stop.reachable, & &1.workable)}/{length(stop.reachable)} stations workable
</div>
<% end %>
<%= if stop.forecast != [] do %>
<div class="flex items-end gap-px h-4 mt-1">
<%= for point <- stop.forecast do %>
<div
class="flex-1 rounded-t-sm"
style={"height: #{max(point.score, 5)}%; background-color: #{tier_color(point.score)}; min-width: 2px"}
title={"#{Calendar.strftime(point.valid_time, "%H:%M")} UTC: #{point.score}"}
>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
<%!-- Route summary --%>
<div class="bg-primary/10 rounded-lg p-2 text-xs">
<div class="font-bold">Route Summary</div>
<div>Unique grid-station pairs: {total_workable(@stops)}</div>
<div>Total driving: {total_driving_km(@stops)} km</div>
<div>
Est. drive time: {Float.round(total_driving_km(@stops) * 1.5 / 100, 1)} hrs
</div>
</div>
<% end %>
<%!-- Selected stop detail --%>
<%= if @stop_detail do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">
Stop Detail: {@stop_detail.grid}
</div>
<%= if @computing_stop do %>
<div class="text-xs text-center py-2">
<span class="loading loading-spinner loading-xs"></span> Computing terrain...
</div>
<% end %>
<%= for r <- @stop_detail.reachable do %>
<div class="flex justify-between items-center text-xs bg-base-200 rounded px-2 py-1">
<div>
<span class="font-bold">{r.label}</span>
<span class="opacity-50 ml-1">{r.dist_km} km / {r.bearing}&deg;</span>
</div>
<div>
<%= if r.verdict do %>
<span class={verdict_badge(r.verdict)}>{r.verdict}</span>
<% end %>
<%= if r.diffraction_db && r.diffraction_db > 0 do %>
<span class="opacity-50 ml-1">{r.diffraction_db} dB</span>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
"""
end
end