prop/lib/microwaveprop_web/live/rover_live.ex

454 lines
15 KiB
Elixir

defmodule MicrowavepropWeb.RoverLive do
@moduledoc """
Rover planner: enter stationary stations you want to work, select a band,
and the map colors areas by coverage quality — how many stations you can
reach from each point given current terrain and propagation conditions.
"""
use MicrowavepropWeb, :live_view
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Rover.Coverage
@band_options BandConfig.band_options()
@default_band 10_000
@initial_bounds %{
"south" => 29.5,
"north" => 36.3,
"west" => -101.5,
"east" => -92.5
}
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
initial_scores = Propagation.latest_scores(@default_band, @initial_bounds)
{:ok,
assign(socket,
page_title: "Rover Planner",
band_options: @band_options,
band: @default_band,
stations: [],
station_input: "",
coverage: [],
selected_grid: nil,
resolving: false,
computing: false,
url_loaded: false,
show_grids: true,
initial_scores_json: Jason.encode!(initial_scores),
bounds: @initial_bounds
)}
end
@impl true
def handle_params(params, _uri, socket) do
if socket.assigns.url_loaded do
{:noreply, socket}
else
band = if params["band"], do: String.to_integer(params["band"]), else: socket.assigns.band
socket = assign(socket, band: band, url_loaded: true)
# Load stations from URL: ?stations=W5ISP,K5TR,EM00cd
case params["stations"] do
nil ->
{:noreply, socket}
stations_str ->
calls = stations_str |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))
send(self(), {:resolve_station_list, calls})
{:noreply, assign(socket, resolving: true)}
end
end
end
# ── Events ──
@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)
socket = assign(socket, stations: stations, coverage: [], selected_grid: nil)
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
{:noreply, push_url(socket)}
end
def handle_event("toggle_grids", _params, socket) do
show = !socket.assigns.show_grids
{:noreply, socket |> assign(show_grids: show) |> push_event("toggle_grids", %{show: show})}
end
def handle_event("select_band", %{"band" => band_str}, socket) do
band = String.to_integer(band_str)
scores = Propagation.latest_scores(band, socket.assigns.bounds)
socket =
socket
|> assign(band: band, coverage: [], selected_grid: nil)
|> push_event("update_scores", %{scores: scores})
{:noreply, push_url(socket)}
end
def handle_event("map_bounds", bounds, socket) do
scores = Propagation.latest_scores(socket.assigns.band, bounds)
socket =
socket
|> assign(:bounds, bounds)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
def handle_event("calculate", _params, socket) do
if length(socket.assigns.stations) >= 2 and not socket.assigns.computing do
send(self(), :compute_coverage)
{:noreply, assign(socket, computing: true)}
else
{:noreply, socket}
end
end
def handle_event("select_grid", %{"grid" => grid}, socket) do
detail = Enum.find(socket.assigns.coverage, &(&1.grid == grid))
{:noreply, assign(socket, selected_grid: detail)}
end
# ── Async ──
def handle_info({:resolve_station_list, calls}, socket) do
stations =
calls
|> Task.async_stream(&resolve_station/1, max_concurrency: 4, timeout: 10_000)
|> Enum.flat_map(fn
{:ok, {:ok, s}} -> [s]
_ -> []
end)
socket =
socket
|> assign(stations: stations, resolving: false)
|> push_event("stations_updated", %{stations: station_data(stations)})
{:noreply, socket}
end
@impl true
def handle_info({:resolve_station, input}, socket) do
case resolve_station(input) do
{:ok, station} ->
stations = socket.assigns.stations ++ [station]
socket = assign(socket, stations: stations, resolving: false)
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
{:noreply, push_url(socket)}
{:error, _reason} ->
{:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")}
end
end
def handle_info({:propagation_updated, _valid_times}, socket) do
scores = Propagation.latest_scores(socket.assigns.band, socket.assigns.bounds)
{:noreply, push_event(socket, "update_scores", %{scores: scores})}
end
def handle_info(:compute_coverage, socket) do
stations = socket.assigns.stations
band_mhz = socket.assigns.band
if length(stations) < 2 do
{:noreply, assign(socket, computing: false)}
else
pid = self()
Task.start(fn ->
try do
result = Coverage.compute(stations, band_mhz)
send(pid, {:coverage_result, result})
rescue
e ->
require Logger
Logger.error("Rover coverage computation failed: #{Exception.message(e)}")
send(pid, {:coverage_result, []})
end
end)
{:noreply, assign(socket, computing: true)}
end
end
def handle_info({:coverage_result, coverage}, socket) do
stations = socket.assigns.stations
grid_data =
Enum.map(coverage, fn c ->
%{
grid: c.grid,
lat: c.lat,
lon: c.lon,
coverage_score: c.coverage_score,
stations_in_range: c.stations_in_range,
workable_count: c.workable_count,
total_stations: length(stations),
prop_score: c.prop_score,
best_hour: c.best_hour
}
end)
socket =
socket
|> assign(coverage: coverage, computing: false, selected_grid: nil)
|> push_event("coverage_updated", %{grids: grid_data})
{:noreply, socket}
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_url(socket) do
labels = Enum.map_join(socket.assigns.stations, ",", & &1.label)
params =
then(%{"band" => to_string(socket.assigns.band)}, fn p ->
if labels == "", do: p, else: Map.put(p, "stations", labels)
end)
push_patch(socket, to: ~p"/rover?#{params}", replace: true)
end
defp station_data(stations) do
Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end)
end
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"
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"
# ── Render ──
@impl true
def render(assigns) do
~H"""
<div class="relative w-screen h-screen overflow-hidden">
<div
id="rover-map"
phx-hook="RoverMap"
phx-update="ignore"
data-band={@band}
data-scores={@initial_scores_json}
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>
<div class="flex gap-2">
<select phx-change="select_band" name="band" class="select select-bordered select-sm flex-1">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@show_grids}
phx-click="toggle_grids"
/>
<span class="text-sm">Grid squares</span>
</label>
</div>
<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>
<%= 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 %>
<%= if length(@stations) >= 2 do %>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm w-full"
disabled={@computing}
>
<%= if @computing do %>
<span class="loading loading-spinner loading-sm"></span>
Finding best roving locations...
<% else %>
Find Best Roving Locations
<% end %>
</button>
<% end %>
<%= if length(@stations) == 1 do %>
<div class="text-xs opacity-50 text-center py-2">
Add at least 2 stations to calculate
</div>
<% end %>
<%!-- Top grids ranked --%>
<%= if @coverage != [] do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">Best Operating Grids</div>
<%= for {grid, rank} <- @coverage |> Enum.take(10) |> Enum.with_index(1) do %>
<div
class={[
"bg-base-200 rounded-lg p-2 text-xs cursor-pointer hover:bg-base-300 transition-colors",
@selected_grid && @selected_grid.grid == grid.grid && "ring-2 ring-primary"
]}
phx-click="select_grid"
phx-value-grid={grid.grid}
>
<div class="flex justify-between items-center">
<div class="font-bold">
<span
class="inline-block w-5 h-5 rounded-full text-center leading-5 text-[10px] font-bold mr-1"
style={"background-color: #{tier_color(grid.coverage_score)}; color: #000"}
>
{rank}
</span>
{grid.grid}
</div>
<div class="font-mono font-bold" style={"color: #{tier_color(grid.coverage_score)}"}>
{grid.coverage_score}
</div>
</div>
<div class="mt-1 flex justify-between opacity-70">
<span>{grid.workable_count}/{length(@stations)} workable</span>
<span>Prop: {grid.prop_score}</span>
</div>
<%= if grid.best_hour do %>
<div class="opacity-50">Best at {grid.best_hour} UTC</div>
<% end %>
<%= if grid.forecast != [] do %>
<div class="flex items-end gap-px h-3 mt-1">
<%= for point <- grid.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 %>
<% end %>
<%!-- Selected grid detail --%>
<%= if @selected_grid do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">
{@selected_grid.grid} — Station Distances
</div>
<%= for s <- Enum.sort_by(@selected_grid.station_details, & &1.dist_km) do %>
<div class="flex justify-between items-center text-xs bg-base-200 rounded px-2 py-1">
<div>
<span class="font-bold">{s.label}</span>
<span class="opacity-50 ml-1">{Float.round(s.dist_km, 0)} km</span>
</div>
<div class="flex gap-1 items-center">
<%= if s.verdict do %>
<span class={verdict_badge(s.verdict)}>{s.verdict}</span>
<% end %>
<%= if s.diffraction_db && s.diffraction_db > 0 do %>
<span class="opacity-50 text-[10px]">{s.diffraction_db} dB</span>
<% end %>
<%= if !s.in_range do %>
<span class="badge badge-error badge-sm">out of range</span>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
"""
end
end