defmodule MicrowavepropWeb.RoverLive do @moduledoc """ Rover planner: enter stationary stations you want to work, select a band, and the map shows the HRRR propagation heatmap with your stations plotted. """ use MicrowavepropWeb, :live_view alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.Maidenhead @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: "", resolving: false, url_loaded: false, 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) socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) {:noreply, push_url(socket)} 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) |> 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 # ── 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 # ── 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 # ── Render ── @impl true def render(assigns) do ~H"""