prop/lib/microwaveprop_web/live/rover_live.ex

717 lines
24 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.RoverLive do
@moduledoc """
Rover-planning page: enter the fixed stations you want to work, pick a
band and forecast hour, and the map paints a smoothed propagation
quality heatmap with the top 5 ranked drive-to candidates pinned in the
bottom strip.
"""
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.Propagation
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.Rover.Compute
alias Microwaveprop.Rover.DriveTime
alias Microwaveprop.Rover.LinkMargin
alias Microwaveprop.Terrain.Srtm
require Logger
@bands [
%{label: "10 GHz", value: 10_000},
%{label: "24 GHz", value: 24_000},
%{label: "47 GHz", value: 47_000}
]
@default_band 10_000
@default_mode :ssb
@default_forecast_hour 0
@default_max_drive_min 120
@avg_speed_kmh 65.0
# NTMS-area anonymous home: EM13 centroid.
@anonymous_home %{label: "EM13", grid: "EM13", lat: 33.5, lon: -97.0, elev_m: nil}
@impl true
def mount(_params, _session, socket) do
{fixed_stations, persisted?} = load_stations(socket)
home = home_for(socket)
valid_times = Propagation.available_valid_times(@default_band)
current_valid_time = Propagation.latest_valid_time(@default_band)
{:ok,
assign(socket,
page_title: "Rover Planner",
bands: @bands,
band: @default_band,
mode: @default_mode,
forecast_hour: @default_forecast_hour,
max_drive_min: @default_max_drive_min,
drive_radius_km: drive_radius_km(@default_max_drive_min),
fixed_stations: fixed_stations,
persisted?: persisted?,
home: home,
valid_times: valid_times,
current_valid_time: current_valid_time,
top_candidates: [],
station_form_error: nil,
home_form_error: nil,
scoring_loading: false
)}
end
# ── Lifecycle helpers ────────────────────────────────────────────────
defp load_stations(socket) do
case current_user(socket) do
%User{} = user ->
case Rover.list_stations(user) do
[] -> {seed_default_stations(user), true}
stations -> {stations, true}
end
_ ->
{Rover.default_stations(), false}
end
end
defp seed_default_stations(user) do
Enum.each(Rover.default_stations(), fn attrs ->
Rover.create_station(user, Map.delete(attrs, :position))
end)
Rover.list_stations(user)
end
defp home_for(socket) do
case current_user(socket) do
%User{home_lat: lat, home_lon: lon, home_grid: grid, home_elevation_m: elev}
when is_float(lat) and is_float(lon) ->
%{label: grid || home_label(lat, lon), grid: grid, lat: lat, lon: lon, elev_m: elev}
_ ->
@anonymous_home
end
end
defp current_user(socket) do
case socket.assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 6)
defp drive_radius_km(max_drive_min), do: max_drive_min * @avg_speed_kmh / 60.0
# ── Events ────────────────────────────────────────────────────────────
@impl true
def handle_event("select_band", %{"band" => band_str}, socket) do
band = parse_int(band_str, @default_band)
{:noreply, assign(socket, band: band)}
end
def handle_event("set_forecast_hour", %{"value" => v}, socket) do
hour = v |> parse_int(@default_forecast_hour) |> clamp(0, 18)
{:noreply, assign(socket, forecast_hour: hour)}
end
def handle_event("set_max_drive_time", %{"value" => v}, socket) do
minutes = v |> parse_int(@default_max_drive_min) |> clamp(30, 240)
radius_km = drive_radius_km(minutes)
{:noreply,
socket
|> assign(max_drive_min: minutes, drive_radius_km: radius_km)
|> push_event("update_drive_radius", %{km: radius_km})}
end
def handle_event("toggle_station", %{"id" => id}, socket) do
handle_station_mutation(socket, id, &toggle_station/2)
end
def handle_event("delete_station", %{"id" => id}, socket) do
handle_station_mutation(socket, id, &delete_station/2)
end
def handle_event("add_station", params, socket) do
callsign = params |> Map.get("callsign", "") |> String.trim()
grid = params |> Map.get("grid", "") |> String.trim()
attrs = build_station_attrs(callsign, grid)
case create_station(socket, attrs) do
{:ok, stations} ->
{:noreply, assign(socket, fixed_stations: stations, station_form_error: nil)}
{:error, msg} ->
{:noreply, assign(socket, station_form_error: msg)}
end
end
def handle_event("set_home_qth", params, socket) do
grid = params |> Map.get("home_grid", "") |> String.trim()
attrs = %{"home_grid" => grid}
case update_home(socket, attrs) do
{:ok, home} ->
{:noreply, assign(socket, home: home, home_form_error: nil)}
{:error, msg} ->
{:noreply, assign(socket, home_form_error: msg)}
end
end
def handle_event("calculate", _params, socket) do
{:noreply, start_scoring(socket)}
end
def handle_event("select_candidate", %{"grid" => grid}, socket) do
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} ->
{:noreply, push_event(socket, "focus_cell", %{lat: lat, lon: lon, zoom: 11})}
:error ->
{:noreply, socket}
end
end
def handle_event("rover_cell_detail", %{"lat" => lat, "lon" => lon}, socket) do
{:noreply, push_cell_popup(socket, lat, lon)}
end
# ── Async ────────────────────────────────────────────────────────────
@impl true
def handle_async(:scoring, {:ok, %{cells: cells, top_candidates: cands}}, socket) do
{:noreply,
socket
|> assign(top_candidates: cands, scoring_loading: false)
|> push_event("rover_results", %{
cells: cells,
drive_radius_km: socket.assigns.drive_radius_km
})}
end
def handle_async(:scoring, {:exit, reason}, socket) do
Logger.error("rover scoring crashed: #{inspect(reason)}")
{:noreply, assign(socket, top_candidates: [], scoring_loading: false)}
end
# ── Mutation helpers ────────────────────────────────────────────────
defp handle_station_mutation(socket, id, fun) do
case fun.(socket, id) do
{:ok, stations} -> {:noreply, assign(socket, fixed_stations: stations)}
{:error, _} -> {:noreply, socket}
end
end
defp toggle_station(%{assigns: %{persisted?: true}} = socket, id) do
user = current_user(socket)
with {:ok, _} <- Rover.toggle_selected(user, id) do
{:ok, Rover.list_stations(user)}
end
end
defp toggle_station(socket, id) do
stations =
Enum.map(socket.assigns.fixed_stations, fn s ->
if station_id(s) == id, do: Map.put(s, :selected, !s.selected), else: s
end)
{:ok, stations}
end
defp delete_station(%{assigns: %{persisted?: true}} = socket, id) do
user = current_user(socket)
with {:ok, _} <- Rover.delete_station(user, id) do
{:ok, Rover.list_stations(user)}
end
end
defp delete_station(socket, id) do
stations = Enum.reject(socket.assigns.fixed_stations, &(station_id(&1) == id))
{:ok, stations}
end
defp create_station(%{assigns: %{persisted?: true}} = socket, attrs) do
user = current_user(socket)
case Rover.create_station(user, attrs) do
{:ok, _station} -> {:ok, Rover.list_stations(user)}
{:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)}
end
end
defp create_station(socket, attrs) do
case build_anonymous_station(attrs, length(socket.assigns.fixed_stations)) do
{:ok, station} -> {:ok, socket.assigns.fixed_stations ++ [station]}
{:error, msg} -> {:error, msg}
end
end
defp build_anonymous_station(%{"callsign" => call} = attrs, position) when is_binary(call) and call != "" do
grid = attrs["grid"]
with {:ok, {lat, lon}} <- resolve_anonymous_latlon(grid, attrs["lat"], attrs["lon"]) do
{:ok,
%{
id: "anon-#{:erlang.unique_integer([:positive])}",
callsign: call |> String.trim() |> String.upcase(),
grid: if(grid && grid != "", do: grid),
lat: lat,
lon: lon,
elevation_m: nil,
selected: true,
position: position
}}
end
end
defp build_anonymous_station(_attrs, _position), do: {:error, "callsign required"}
defp resolve_anonymous_latlon(grid, _lat, _lon) when is_binary(grid) and grid != "" do
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} -> {:ok, {lat, lon}}
:error -> {:error, "invalid grid"}
end
end
defp resolve_anonymous_latlon(_grid, lat, lon) when is_number(lat) and is_number(lon) do
{:ok, {lat * 1.0, lon * 1.0}}
end
defp resolve_anonymous_latlon(_grid, _lat, _lon), do: {:error, "grid or lat/lon required"}
defp build_station_attrs(callsign, grid) do
%{}
|> put_if_present("callsign", callsign)
|> put_if_present("grid", grid)
end
defp put_if_present(map, _key, ""), do: map
defp put_if_present(map, key, val), do: Map.put(map, key, val)
defp update_home(%{assigns: %{persisted?: true}} = socket, attrs) do
user = current_user(socket)
case user |> User.change_home_qth(attrs) |> Repo.update() do
{:ok, %User{} = updated} ->
{:ok,
%{
label: updated.home_grid || home_label(updated.home_lat, updated.home_lon),
grid: updated.home_grid,
lat: updated.home_lat,
lon: updated.home_lon,
elev_m: updated.home_elevation_m
}}
{:error, %Ecto.Changeset{} = cs} ->
{:error, changeset_first_error(cs)}
end
end
defp update_home(_socket, %{"home_grid" => grid}) when is_binary(grid) and grid != "" do
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} ->
{:ok, %{label: grid, grid: grid, lat: lat, lon: lon, elev_m: nil}}
:error ->
{:error, "invalid grid"}
end
end
defp update_home(_socket, _attrs), do: {:error, "grid required"}
defp changeset_first_error(%Ecto.Changeset{errors: [{field, {msg, _}} | _]}) do
"#{field}: #{msg}"
end
defp changeset_first_error(_), do: "could not save"
# ── Cell popup ───────────────────────────────────────────────────────
defp push_cell_popup(socket, lat, lon) do
%{
band: band,
mode: mode,
current_valid_time: valid_time,
fixed_stations: stations,
home: home
} = socket.assigns
grid = Maidenhead.from_latlon(lat, lon, 6)
elev_m = lookup_elev(lat, lon)
dist_km = haversine_km(home, %{lat: lat, lon: lon})
drive_min = dist_km / @avg_speed_kmh * 60.0
rows =
stations
|> Enum.filter(& &1.selected)
|> Enum.map(fn s -> link_row(s, band, valid_time, lat, lon, mode) end)
html = render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows)
push_event(socket, "show_cell_popup", %{lat: lat, lon: lon, html: html})
end
defp link_row(station, band, valid_time, lat, lon, mode) do
case LinkMargin.link_margin_db(
band,
valid_time,
{lat, lon},
{station.lat, station.lon},
mode
) do
nil -> %{call: station.callsign, predicted_db: nil, margin: nil}
margin -> %{call: station.callsign, predicted_db: margin, margin: margin}
end
end
defp render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows) do
rows_html =
Enum.map_join(rows, "", fn r ->
margin_str =
case r.margin do
nil -> ""
m -> :erlang.float_to_binary(m, decimals: 1) <> " dB"
end
~s(<tr><td style="padding-right:8px">#{r.call}</td><td style="text-align:right">#{margin_str}</td></tr>)
end)
elev_str = if is_integer(elev_m), do: "#{elev_m} m", else: ""
"""
<div style="font-size:12px;min-width:200px">
<div style="font-weight:600;font-size:13px">#{grid}</div>
<div style="opacity:0.7">#{elev_str} · #{Float.round(drive_min, 0)} min · #{Float.round(dist_km, 0)} km</div>
<table style="margin-top:6px;border-collapse:collapse;width:100%">#{rows_html}</table>
<div style="margin-top:6px;display:flex;gap:8px">
<a href="https://maps.apple.com/?q=#{grid}&ll=#{Float.round(:erlang.element(1, grid |> Maidenhead.to_latlon() |> elem(1)), 4)}" target="_blank" rel="noopener" style="color:#3b82f6">Open in Maps</a>
</div>
</div>
"""
rescue
_ -> "<div style=\"font-size:12px\">Cell detail unavailable</div>"
end
defp lookup_elev(lat, lon) do
tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm")
case Srtm.lookup(lat, lon, tiles_dir) do
{:ok, e} -> e
_ -> nil
end
end
defp haversine_km(%{lat: lat1, lon: lon1}, %{lat: lat2, lon: lon2}) do
DriveTime.haversine_km({lat1, lon1}, {lat2, lon2})
end
# ── Calculate ───────────────────────────────────────────────────────
defp start_scoring(socket) do
args = scoring_args(socket)
socket
|> assign(scoring_loading: true)
|> start_async(:scoring, fn -> Compute.run(args) end)
end
defp scoring_args(socket) do
%{
home: socket.assigns.home,
stations: stations_for_compute(socket.assigns.fixed_stations),
band_mhz: socket.assigns.band,
valid_time: socket.assigns.current_valid_time || DateTime.utc_now(),
mode: socket.assigns.mode,
max_drive_min: socket.assigns.max_drive_min,
min_elev_gain: 0
}
end
defp stations_for_compute(stations) do
Enum.map(stations, fn s ->
%{
callsign: s.callsign,
lat: s.lat,
lon: s.lon,
selected: !!s.selected
}
end)
end
# ── Helpers ─────────────────────────────────────────────────────────
defp parse_int(v, default) do
case Integer.parse("#{v}") do
{n, _} -> n
:error -> default
end
end
defp clamp(v, lo, hi), do: v |> max(lo) |> min(hi)
defp band_label(band) do
Enum.find_value(@bands, "#{band} MHz", fn b -> if b.value == band, do: b.label end)
end
defp station_id(%{id: id}), do: to_string(id)
defp station_id(_), do: nil
# ── Render ───────────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div class="flex flex-col w-screen h-screen overflow-hidden">
<header class="flex items-center justify-between h-11 px-4 bg-base-100 border-b border-base-300 shrink-0">
<div class="flex flex-col leading-tight">
<span class="font-semibold text-sm">Rover planning</span>
<span class="text-xs text-base-content/60">
{band_label(@band)} · Forecast +{@forecast_hour}h
</span>
</div>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm"
disabled={@scoring_loading}
>
<span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
{if @scoring_loading, do: "Calculating…", else: "Calculate"}
</button>
</header>
<div class="flex flex-1 min-h-0">
<div class="relative flex-1 min-w-0">
<div
id="rover-map"
phx-hook="RoverMap"
phx-update="ignore"
data-home-lat={@home.lat}
data-home-lon={@home.lon}
data-drive-radius-km={@drive_radius_km}
data-stations={Jason.encode!(stations_for_compute(@fixed_stations))}
class={[
"absolute inset-0 z-0 transition-opacity",
@scoring_loading && "opacity-60"
]}
>
</div>
<div
:if={@top_candidates != []}
class="absolute bottom-0 inset-x-0 h-[110px] bg-base-100/90 backdrop-blur border-t border-base-300 z-10 overflow-x-auto"
>
<div class="carousel carousel-start gap-3 p-3 h-full">
<div
:for={c <- @top_candidates}
class="carousel-item w-[280px] card bg-base-200 shadow-sm cursor-pointer"
phx-click="select_candidate"
phx-value-grid={c.grid}
>
<div class="card-body p-3">
<div class="flex items-center justify-between">
<span class="font-mono font-semibold">{c.grid}</span>
<span
class="badge badge-sm font-mono"
style={"background-color: #{c.tier_color}; color: #000"}
>
{Float.round(c.score, 1)} dB
</span>
</div>
<div class="text-xs opacity-70">
{round(c.distance_km)} km {c.bearing_compass} of home
</div>
<div class="text-xs opacity-70">
{c.elev_m} m · {round(c.drive_min)} min drive
</div>
</div>
</div>
</div>
</div>
<div class="absolute bottom-[122px] left-4 z-20 bg-base-100/90 rounded-box shadow p-2 text-xs flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #16a34a; opacity: 0.5"></span>
<span>Excellent ≥10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #eab308; opacity: 0.5"></span>
<span>Good 3..10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #f97316; opacity: 0.5"></span>
<span>Marginal 0..3 dB</span>
</div>
<div class="flex items-center gap-2 pt-1 border-t border-base-300/50">
<span class="w-4 border-t-2 border-dashed border-sky-500"></span>
<span>Drive radius</span>
</div>
</div>
</div>
<aside
id="rover-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-72 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-y-auto"
>
<.sidebar_section title="HOME">
<.home_form home={@home} error={@home_form_error} persisted?={@persisted?} />
</.sidebar_section>
<.sidebar_section title="FIXED STATIONS">
<ul class="menu menu-sm p-0 gap-0.5">
<li :for={s <- @fixed_stations}>
<label class="cursor-pointer flex items-center gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-xs"
checked={s.selected}
phx-click="toggle_station"
phx-value-id={station_id(s)}
/>
<span class="font-mono text-xs flex-1">{s.callsign}</span>
<span :if={s.grid} class="opacity-60 text-[11px]">{s.grid}</span>
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100"
phx-click="delete_station"
phx-value-id={station_id(s)}
title="Remove"
>
×
</button>
</label>
</li>
</ul>
<form phx-submit="add_station" class="flex gap-1 mt-2">
<input
type="text"
name="callsign"
placeholder="Call"
class="input input-bordered input-xs flex-1"
autocomplete="off"
/>
<input
type="text"
name="grid"
placeholder="Grid"
class="input input-bordered input-xs w-20"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs btn-primary">+</button>
</form>
<p :if={@station_form_error} class="text-error text-[11px] mt-1">
{@station_form_error}
</p>
<p :if={!@persisted?} class="text-[11px] opacity-70 mt-2">
<.link href={~p"/users/log-in"} class="link link-primary">Sign in</.link>
to save your station list.
</p>
</.sidebar_section>
<.sidebar_section title="FORECAST HOUR">
<input
type="range"
min="0"
max="18"
step="1"
value={@forecast_hour}
phx-change="set_forecast_hour"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">+{@forecast_hour}h</div>
</.sidebar_section>
<.sidebar_section title="BAND">
<div class="join w-full">
<button
:for={b <- @bands}
type="button"
phx-click="select_band"
phx-value-band={b.value}
class={[
"btn btn-sm join-item flex-1",
@band == b.value && "btn-primary"
]}
>
{b.label |> String.replace(" GHz", "G")}
</button>
</div>
</.sidebar_section>
<.sidebar_section title="MAX DRIVE TIME">
<input
type="range"
min="30"
max="240"
step="15"
value={@max_drive_min}
phx-change="set_max_drive_time"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">
{Float.round(@max_drive_min / 60, 1)}h from {@home.label}
</div>
</.sidebar_section>
<div class="mt-auto p-3 text-[11px] text-neutral-content/50 border-t border-base-300/50">
Tip: click any cell to see per-station predicted SNR.
</div>
</aside>
</div>
</div>
"""
end
attr :title, :string, required: true
slot :inner_block, required: true
defp sidebar_section(assigns) do
~H"""
<section class="px-3 py-3 border-t border-base-300/50 first:border-t-0">
<h3 class="text-[10px] uppercase tracking-wider text-neutral-content/60 mb-2">{@title}</h3>
{render_slot(@inner_block)}
</section>
"""
end
attr :home, :map, required: true
attr :error, :string, default: nil
attr :persisted?, :boolean, required: true
defp home_form(assigns) do
~H"""
<form phx-submit="set_home_qth" class="flex gap-1">
<input
type="text"
name="home_grid"
value={@home.grid || @home.label}
placeholder="Home grid"
class="input input-bordered input-xs flex-1 font-mono"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs">Set</button>
</form>
<p :if={@error} class="text-error text-[11px] mt-1">{@error}</p>
<p :if={!@persisted?} class="text-[11px] opacity-70 mt-1">
<.link href={~p"/users/log-in"} class="link link-primary">Sign in</.link> to save.
</p>
"""
end
end