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"""
<%!-- Map --%>
<%!-- Sidebar --%>
Rover Planner
<%!-- Band selector --%> <%!-- Add station --%>
<%!-- Station list --%> <%= if @stations != [] do %>
Stationary Stations ({length(@stations)})
<%= for {station, idx} <- Enum.with_index(@stations) do %>
{station.label}
<% end %>
<% end %> <%!-- Instructions --%> <%= if @stations != [] and @stops == [] do %>
Click the map to add operating positions
<% end %> <%!-- Route stops --%> <%= if @stops != [] do %>
Route ({length(@stops)} stops, {total_driving_km(@stops)} km driving)
<%= for {stop, idx} <- Enum.with_index(@stops) do %>
{idx + 1} {stop.grid}
<%= if stop.score do %> {stop.score} <% end %>
<%= if stop.reachable != [] do %>
{Enum.count(stop.reachable, & &1.workable)}/{length(stop.reachable)} stations workable
<% end %> <%= if stop.forecast != [] do %>
<%= for point <- stop.forecast do %>
<% end %>
<% end %>
<% end %> <%!-- Route summary --%>
Route Summary
Unique grid-station pairs: {total_workable(@stops)}
Total driving: {total_driving_km(@stops)} km
Est. drive time: {Float.round(total_driving_km(@stops) * 1.5 / 100, 1)} hrs
<% end %> <%!-- Selected stop detail --%> <%= if @stop_detail do %>
Stop Detail: {@stop_detail.grid}
<%= if @computing_stop do %>
Computing terrain...
<% end %> <%= for r <- @stop_detail.reachable do %>
{r.label} {r.dist_km} km / {r.bearing}°
<%= if r.verdict do %> {r.verdict} <% end %> <%= if r.diffraction_db && r.diffraction_db > 0 do %> {r.diffraction_db} dB <% end %>
<% end %> <% end %>
""" end end