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.Radio.CallsignClient alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Rover.Coverage @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: "", coverage: [], selected_grid: nil, resolving: false, computing: false, url_loaded: false, show_grids: true )} 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 socket = assign(socket, band: String.to_integer(band_str), coverage: [], selected_grid: nil) {:noreply, push_url(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(: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"""