1332 lines
44 KiB
Elixir
1332 lines
44 KiB
Elixir
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
|
||
|
||
import MicrowavepropWeb.LiveHelpers, only: [parse_int: 2]
|
||
|
||
alias Microwaveprop.Accounts.User
|
||
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
||
alias Microwaveprop.Geocoder
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Radio.CallsignClient
|
||
alias Microwaveprop.Radio.Maidenhead
|
||
alias Microwaveprop.Repo
|
||
alias Microwaveprop.Rover
|
||
alias Microwaveprop.Rover.CandidateDetail
|
||
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_distance_mi 50
|
||
@km_per_mi 1.609344
|
||
@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)
|
||
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_distance_mi: @default_max_distance_mi,
|
||
drive_radius_km: drive_radius_km(@default_max_distance_mi),
|
||
fixed_stations: fixed_stations,
|
||
persisted?: persisted?,
|
||
home: home,
|
||
current_valid_time: current_valid_time,
|
||
top_candidates: [],
|
||
selected_candidate: nil,
|
||
station_form_error: nil,
|
||
home_form_error: nil,
|
||
scoring_loading: false,
|
||
scoring_step: nil,
|
||
only_good_locations: false,
|
||
url_loaded?: false
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_params(params, _uri, socket) do
|
||
if socket.assigns.url_loaded? do
|
||
{:noreply, socket}
|
||
else
|
||
socket =
|
||
socket
|
||
|> apply_url_stations(params)
|
||
|> assign(url_loaded?: true)
|
||
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
defp apply_url_stations(%{assigns: %{persisted?: true}} = socket, _params), do: socket
|
||
|
||
defp apply_url_stations(socket, %{"stations" => stations_str}) when is_binary(stations_str) do
|
||
stations = parse_url_stations(stations_str)
|
||
if stations == [], do: socket, else: assign(socket, fixed_stations: stations)
|
||
end
|
||
|
||
defp apply_url_stations(socket, _params), do: socket
|
||
|
||
defp parse_url_stations(str) do
|
||
str
|
||
|> String.split(",", trim: true)
|
||
|> Enum.with_index()
|
||
|> Enum.flat_map(fn {entry, idx} -> entry |> parse_url_station_entry(idx) |> List.wrap() end)
|
||
end
|
||
|
||
defp parse_url_station_entry("-" <> rest, idx), do: parse_url_station_pair(rest, idx, false)
|
||
defp parse_url_station_entry(entry, idx), do: parse_url_station_pair(entry, idx, true)
|
||
|
||
defp parse_url_station_pair(entry, idx, selected?) do
|
||
with [call, grid] <- String.split(entry, ":", parts: 2),
|
||
call = String.upcase(String.trim(call)),
|
||
grid = String.trim(grid),
|
||
true <- call != "",
|
||
{:ok, {lat, lon}} <- Maidenhead.to_latlon(grid) do
|
||
%{
|
||
id: "url-#{idx}",
|
||
callsign: call,
|
||
grid: grid,
|
||
lat: lat,
|
||
lon: lon,
|
||
elevation_m: nil,
|
||
selected: selected?,
|
||
position: idx
|
||
}
|
||
else
|
||
_ -> nil
|
||
end
|
||
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, 10)
|
||
|
||
defp drive_radius_km(max_distance_mi), do: max_distance_mi * @km_per_mi
|
||
|
||
# ── 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, 48)
|
||
{:noreply, assign(socket, forecast_hour: hour)}
|
||
end
|
||
|
||
def handle_event("set_max_distance", %{"value" => v}, socket) do
|
||
miles = v |> parse_int(@default_max_distance_mi) |> clamp(25, 250)
|
||
radius_km = drive_radius_km(miles)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(max_distance_mi: miles, drive_radius_km: radius_km)
|
||
|> push_event("update_drive_radius", %{km: radius_km})}
|
||
end
|
||
|
||
def handle_event("toggle_only_good", _params, socket) do
|
||
{:noreply, assign(socket, only_good_locations: not socket.assigns.only_good_locations)}
|
||
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()
|
||
|
||
case resolve_station_grid(callsign, grid) do
|
||
{:ok, resolved_grid} ->
|
||
attrs = build_station_attrs(callsign, resolved_grid)
|
||
|
||
case create_station(socket, attrs) do
|
||
{:ok, stations} ->
|
||
{:noreply,
|
||
socket
|
||
|> assign_stations(stations)
|
||
|> assign(station_form_error: nil)
|
||
|> sync_url()}
|
||
|
||
{:error, msg} ->
|
||
{:noreply, assign(socket, station_form_error: msg)}
|
||
end
|
||
|
||
{:error, msg} ->
|
||
{:noreply, assign(socket, station_form_error: msg)}
|
||
end
|
||
end
|
||
|
||
def handle_event("update_station_grid", %{"station_id" => id, "grid" => new_grid}, socket) do
|
||
new_grid = String.trim(new_grid)
|
||
|
||
cond do
|
||
new_grid == "" ->
|
||
{:noreply, socket}
|
||
|
||
not Maidenhead.valid?(new_grid) ->
|
||
{:noreply, assign(socket, station_form_error: "invalid grid: #{new_grid}")}
|
||
|
||
true ->
|
||
case mutate_station_grid(socket, id, new_grid) do
|
||
{:ok, stations} ->
|
||
{:noreply,
|
||
socket
|
||
|> assign_stations(stations)
|
||
|> assign(station_form_error: nil)
|
||
|> sync_url()}
|
||
|
||
{:error, msg} ->
|
||
{:noreply, assign(socket, station_form_error: msg)}
|
||
end
|
||
end
|
||
end
|
||
|
||
def handle_event("set_home_qth", params, socket) do
|
||
input = params |> Map.get("home_grid", "") |> String.trim()
|
||
|
||
case resolve_home_input(input) do
|
||
{:ok, %{lat: lat, lon: lon, grid: grid}} ->
|
||
case update_home(socket, %{"home_grid" => grid, "home_lat" => lat, "home_lon" => lon}) do
|
||
{:ok, home} ->
|
||
{:noreply,
|
||
socket
|
||
|> assign(home: home, home_form_error: nil)
|
||
|> push_event("update_home", %{lat: home.lat, lon: home.lon})}
|
||
|
||
{:error, msg} ->
|
||
{:noreply, assign(socket, home_form_error: msg)}
|
||
end
|
||
|
||
{: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 find_candidate(socket.assigns.top_candidates, grid) do
|
||
nil ->
|
||
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
|
||
|
||
candidate ->
|
||
detail = build_candidate_detail(candidate, socket)
|
||
paths = candidate_path_payload(detail, socket.assigns.fixed_stations)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(selected_candidate: detail)
|
||
|> push_event("focus_cell", %{lat: candidate.lat, lon: candidate.lon, zoom: 11})
|
||
|> push_event("candidate_paths", %{
|
||
candidate: %{lat: candidate.lat, lon: candidate.lon},
|
||
paths: paths
|
||
})
|
||
|> push_event("candidate_buildings", %{
|
||
buildings: buildings_along_paths(candidate, paths)
|
||
})}
|
||
end
|
||
end
|
||
|
||
def handle_event("close_candidate_detail", _params, socket) do
|
||
{:noreply,
|
||
socket
|
||
|> assign(selected_candidate: nil)
|
||
|> push_event("candidate_paths", %{candidate: nil, paths: []})
|
||
|> push_event("candidate_buildings", %{buildings: []})}
|
||
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} = result}, socket) do
|
||
warnings = Map.get(result, :warnings, [])
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(
|
||
top_candidates: cands,
|
||
selected_candidate: nil,
|
||
scoring_loading: false,
|
||
scoring_step: nil
|
||
)
|
||
|> apply_scoring_warnings(warnings)
|
||
|> push_event("candidate_paths", %{candidate: nil, paths: []})
|
||
|> 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,
|
||
socket
|
||
|> assign(top_candidates: [], scoring_loading: false, scoring_step: nil)
|
||
|> put_flash(:error, "Calculate failed: #{inspect(reason)}")}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:rover_progress, label, n, total}, socket) do
|
||
{:noreply, assign(socket, scoring_step: "#{n}/#{total} · #{label}")}
|
||
end
|
||
|
||
defp apply_scoring_warnings(socket, []), do: clear_flash(socket)
|
||
|
||
defp apply_scoring_warnings(socket, warnings) do
|
||
put_flash(socket, :info, Enum.join(warnings, " "))
|
||
end
|
||
|
||
# When the user supplies just a callsign, look it up via QRZ (with the
|
||
# Google Maps geocoder fallback already wired into CallsignClient) and
|
||
# use the returned 10-char grid. Explicit user-entered grids win over
|
||
# the lookup so an operator can override an old QRZ entry.
|
||
defp resolve_station_grid(_call, grid) when is_binary(grid) and grid != "", do: {:ok, grid}
|
||
|
||
defp resolve_station_grid("", _grid), do: {:error, "callsign required"}
|
||
|
||
defp resolve_station_grid(call, _grid) do
|
||
case CallsignClient.locate(call) do
|
||
{:ok, %{gridsquare: g}} when is_binary(g) and byte_size(g) >= 4 ->
|
||
{:ok, g}
|
||
|
||
{:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) ->
|
||
{:ok, Maidenhead.from_latlon(lat, lon, 10)}
|
||
|
||
{:ok, _} ->
|
||
{:error, "QRZ lookup for #{call} returned no grid or coords"}
|
||
|
||
{:error, reason} ->
|
||
{:error, "couldn't locate #{call}: #{inspect(reason)}"}
|
||
end
|
||
end
|
||
|
||
# ── Mutation helpers ────────────────────────────────────────────────
|
||
|
||
defp handle_station_mutation(socket, id, fun) do
|
||
case fun.(socket, id) do
|
||
{:ok, stations} ->
|
||
{:noreply, socket |> assign_stations(stations) |> sync_url()}
|
||
|
||
{:error, reason} ->
|
||
Logger.error("RoverLive station mutation failed: id=#{inspect(id)} reason=#{inspect(reason)}")
|
||
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
defp assign_stations(socket, stations) do
|
||
socket
|
||
|> assign(fixed_stations: stations)
|
||
|> push_event("stations_updated", %{stations: stations_for_compute(stations)})
|
||
end
|
||
|
||
defp sync_url(socket) do
|
||
push_patch(socket, to: rover_path(socket), replace: true)
|
||
end
|
||
|
||
defp rover_path(socket) do
|
||
case build_url_stations(socket.assigns.fixed_stations) do
|
||
"" -> ~p"/rover"
|
||
str -> ~p"/rover?#{[stations: str]}"
|
||
end
|
||
end
|
||
|
||
defp build_url_stations(stations) do
|
||
stations
|
||
|> Enum.map(&encode_url_station/1)
|
||
|> Enum.reject(&is_nil/1)
|
||
|> Enum.join(",")
|
||
end
|
||
|
||
defp encode_url_station(%{callsign: call, grid: grid, selected: sel}) when is_binary(grid) and grid != "" do
|
||
prefix = if sel, do: "", else: "-"
|
||
"#{prefix}#{call}:#{grid}"
|
||
end
|
||
|
||
defp encode_url_station(_), do: nil
|
||
|
||
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 mutate_station_grid(%{assigns: %{persisted?: true}} = socket, id, new_grid) do
|
||
user = current_user(socket)
|
||
|
||
case Rover.update_station(user, id, %{"grid" => new_grid, "lat" => nil, "lon" => nil}) do
|
||
{:ok, _} -> {:ok, Rover.list_stations(user)}
|
||
{:error, :not_found} -> {:error, "station not found"}
|
||
{:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)}
|
||
end
|
||
end
|
||
|
||
defp mutate_station_grid(socket, id, new_grid) do
|
||
stations =
|
||
Enum.map(socket.assigns.fixed_stations, fn s ->
|
||
if station_id(s) == id, do: replace_anonymous_station_grid(s, new_grid), else: s
|
||
end)
|
||
|
||
{:ok, stations}
|
||
end
|
||
|
||
defp replace_anonymous_station_grid(station, new_grid) do
|
||
case Maidenhead.to_latlon(new_grid) do
|
||
{:ok, {lat, lon}} -> %{station | grid: new_grid, lat: lat, lon: lon}
|
||
:error -> station
|
||
end
|
||
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_lat" => lat, "home_lon" => lon} = attrs) when is_number(lat) and is_number(lon) do
|
||
grid = Map.get(attrs, "home_grid") || home_label(lat * 1.0, lon * 1.0)
|
||
{:ok, %{label: grid, grid: grid, lat: lat * 1.0, lon: lon * 1.0, elev_m: nil}}
|
||
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"}
|
||
|
||
# Accepts grid (EM13qc), callsign (W5ABC), or address ("100 Main St, Dallas TX").
|
||
# Returns {:ok, %{lat, lon, grid}} or {:error, "..."}.
|
||
defp resolve_home_input(""), do: {:error, "enter a grid, callsign, or address"}
|
||
|
||
defp resolve_home_input(input) do
|
||
cond do
|
||
Maidenhead.valid?(input) -> resolve_home_grid(input)
|
||
callsign?(input) -> resolve_home_callsign(input)
|
||
true -> resolve_home_address(input)
|
||
end
|
||
end
|
||
|
||
defp resolve_home_grid(grid) do
|
||
case Maidenhead.to_latlon(grid) do
|
||
{:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, grid: grid}}
|
||
:error -> {:error, "invalid grid"}
|
||
end
|
||
end
|
||
|
||
defp resolve_home_callsign(call) do
|
||
case CallsignClient.locate(call) do
|
||
{:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}}
|
||
{:error, msg} -> {:error, "callsign: #{msg}"}
|
||
end
|
||
end
|
||
|
||
defp resolve_home_address(addr) do
|
||
case Geocoder.geocode(addr) do
|
||
{:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}}
|
||
{:error, msg} -> {:error, "geocode: #{msg}"}
|
||
end
|
||
end
|
||
|
||
defp callsign?(input) when is_binary(input) do
|
||
String.match?(input, ~r/^[A-Za-z0-9\/]{3,12}$/) and not Maidenhead.valid?(input)
|
||
end
|
||
|
||
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, 10)
|
||
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)
|
||
pid = self()
|
||
progress = fn label, n, total -> send(pid, {:rover_progress, label, n, total}) end
|
||
|
||
socket
|
||
|> assign(scoring_loading: true, scoring_step: "Starting…")
|
||
|> start_async(:scoring, fn -> Compute.run(args, progress: progress) end)
|
||
end
|
||
|
||
defp scoring_args(socket) do
|
||
base = %{
|
||
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_distance_km: socket.assigns.drive_radius_km,
|
||
min_elev_gain: 0
|
||
}
|
||
|
||
if socket.assigns.only_good_locations do
|
||
goods =
|
||
Rover.list_locations()
|
||
|> Enum.filter(&(&1.status == :good))
|
||
|> Enum.map(fn l -> %{lat: l.lat, lon: l.lon} end)
|
||
|
||
Map.put(base, :good_locations, goods)
|
||
else
|
||
base
|
||
end
|
||
end
|
||
|
||
defp find_candidate(candidates, grid) do
|
||
Enum.find(candidates, fn c -> c.grid == grid end)
|
||
end
|
||
|
||
defp build_candidate_detail(candidate, socket) do
|
||
selected = Enum.filter(socket.assigns.fixed_stations, & &1.selected)
|
||
|
||
summary =
|
||
CandidateDetail.summarize(
|
||
candidate,
|
||
selected,
|
||
socket.assigns.band,
|
||
socket.assigns.current_valid_time || DateTime.utc_now(),
|
||
socket.assigns.mode
|
||
)
|
||
|
||
Map.merge(summary, %{
|
||
candidate: candidate,
|
||
links: Enum.map(summary.links, &decorate_link_profile/1)
|
||
})
|
||
end
|
||
|
||
# Pre-render the elevation profile as an SVG path so the template
|
||
# doesn't have to do float math inline.
|
||
defp decorate_link_profile(link) do
|
||
Map.put(link, :profile_svg, profile_to_svg(link.profile))
|
||
end
|
||
|
||
defp buildings_along_paths(candidate, paths) do
|
||
paths
|
||
|> Enum.flat_map(fn p ->
|
||
sample_along({candidate.lat, candidate.lon}, {p.station_lat, p.station_lon})
|
||
end)
|
||
|> Enum.flat_map(fn {lat, lon} ->
|
||
BuildingsIndex.records_near(lat, lon, 150)
|
||
end)
|
||
|> Enum.uniq_by(fn r -> {r.centroid_lat, r.centroid_lon} end)
|
||
|> Enum.map(fn r ->
|
||
%{
|
||
lat: r.centroid_lat,
|
||
lon: r.centroid_lon,
|
||
radius_m: r.max_radius_m,
|
||
height_m: r.height_m
|
||
}
|
||
end)
|
||
end
|
||
|
||
defp sample_along({lat1, lon1}, {lat2, lon2}) do
|
||
n = 24
|
||
|
||
for i <- 1..n do
|
||
f = i / (n + 1)
|
||
{lat1 + f * (lat2 - lat1), lon1 + f * (lon2 - lon1)}
|
||
end
|
||
end
|
||
|
||
defp candidate_path_payload(detail, fixed_stations) do
|
||
by_callsign = Map.new(fixed_stations, fn s -> {s.callsign, s} end)
|
||
|
||
Enum.flat_map(detail.links, fn link ->
|
||
case Map.get(by_callsign, link.callsign) do
|
||
nil ->
|
||
[]
|
||
|
||
station ->
|
||
[
|
||
%{
|
||
callsign: link.callsign,
|
||
station_lat: station.lat,
|
||
station_lon: station.lon,
|
||
margin_db: link.margin_db
|
||
}
|
||
]
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp profile_to_svg([]), do: nil
|
||
|
||
defp profile_to_svg(points) do
|
||
tops =
|
||
Enum.map(points, fn pt ->
|
||
pt.elev + round(max(Map.get(pt, :building_m, 0.0), Map.get(pt, :canopy_m, 0)))
|
||
end)
|
||
|
||
elevs = Enum.map(points, & &1.elev)
|
||
min_e = Enum.min(elevs)
|
||
max_e = max(Enum.max(elevs), Enum.max(tops))
|
||
span = max(max_e - min_e, 1)
|
||
first = List.first(points)
|
||
last = List.last(points)
|
||
total_km = if last && last.dist_km > 0, do: last.dist_km, else: 1.0
|
||
|
||
line =
|
||
Enum.map_join(points, " ", fn pt ->
|
||
x = pt.dist_km / total_km * 240.0
|
||
y = 60.0 - (pt.elev - min_e) / span * 50.0
|
||
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
|
||
end)
|
||
|
||
fill = "0,60 " <> line <> " 240,60"
|
||
|
||
has_buildings? = Enum.any?(points, fn pt -> Map.get(pt, :building_m, 0.0) > 0.5 end)
|
||
has_canopy? = Enum.any?(points, fn pt -> Map.get(pt, :canopy_m, 0) > 1 end)
|
||
|
||
terrain_back =
|
||
points
|
||
|> Enum.reverse()
|
||
|> Enum.map_join(" ", fn pt ->
|
||
x = pt.dist_km / total_km * 240.0
|
||
y = 60.0 - (pt.elev - min_e) / span * 50.0
|
||
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
|
||
end)
|
||
|
||
obstacle_fill = fn obstacle_key ->
|
||
obstacle_line =
|
||
Enum.map_join(points, " ", fn pt ->
|
||
x = pt.dist_km / total_km * 240.0
|
||
top = pt.elev + round(Map.get(pt, obstacle_key, 0))
|
||
y = 60.0 - (top - min_e) / span * 50.0
|
||
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
|
||
end)
|
||
|
||
obstacle_line <> " " <> terrain_back
|
||
end
|
||
|
||
canopy_fill = if has_canopy?, do: obstacle_fill.(:canopy_m)
|
||
building_fill = if has_buildings?, do: obstacle_fill.(:building_m)
|
||
|
||
los_y_start = Float.round(60.0 - (first.elev - min_e) / span * 50.0, 2)
|
||
los_y_end = Float.round(60.0 - (last.elev - min_e) / span * 50.0, 2)
|
||
|
||
%{
|
||
line: line,
|
||
fill: fill,
|
||
canopy_fill: canopy_fill,
|
||
building_fill: building_fill,
|
||
start_elev_m: first.elev,
|
||
end_elev_m: last.elev,
|
||
total_km: total_km,
|
||
los_y_start: los_y_start,
|
||
los_y_end: los_y_end
|
||
}
|
||
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 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}) when not is_nil(id), do: to_string(id)
|
||
defp station_id(%{callsign: call}) when is_binary(call), do: "call:" <> call
|
||
defp station_id(_), do: ""
|
||
|
||
defp km_to_mi(km) when is_number(km), do: round(km * 0.621371)
|
||
defp km_to_mi(_), do: 0
|
||
|
||
defp elev_ft(nil), do: "—"
|
||
defp elev_ft(m) when is_number(m), do: "#{round(m * 3.28084)} ft"
|
||
|
||
defp format_margin(nil), do: "— dB"
|
||
defp format_margin(db) when is_number(db), do: :erlang.float_to_binary(db * 1.0, decimals: 1) <> " dB"
|
||
|
||
defp clearance_label(nil), do: "—"
|
||
|
||
defp clearance_label(m) when is_number(m) do
|
||
sign = if m >= 0, do: "+", else: ""
|
||
"#{sign}#{round(m * 3.28084)} ft"
|
||
end
|
||
|
||
# ── 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>
|
||
<div class="flex items-center gap-3">
|
||
<span
|
||
:if={@scoring_loading and @scoring_step}
|
||
class="text-xs text-base-content/60 font-mono"
|
||
>
|
||
{@scoring_step}
|
||
</span>
|
||
<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>
|
||
</div>
|
||
</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>
|
||
|
||
<.candidate_detail_panel :if={@selected_candidate} detail={@selected_candidate} />
|
||
|
||
<div
|
||
:if={@top_candidates != []}
|
||
class="absolute bottom-0 inset-x-0 bg-base-100/90 backdrop-blur border-t border-base-300 z-10"
|
||
>
|
||
<div class="px-3 pt-1 text-[10px] uppercase tracking-wider text-base-content/60 font-semibold">
|
||
Good locations
|
||
</div>
|
||
<div
|
||
id="rover-candidates-strip"
|
||
phx-hook="HorizontalWheelScroll"
|
||
class="flex flex-row flex-nowrap gap-3 px-3 pb-3 pt-1 h-[110px] overflow-x-auto overflow-y-hidden overscroll-x-contain"
|
||
>
|
||
<div
|
||
:for={c <- @top_candidates}
|
||
class="shrink-0 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">
|
||
{km_to_mi(c.distance_km)} mi {c.bearing_compass} of home
|
||
</div>
|
||
<div class="text-xs opacity-70">
|
||
{elev_ft(c.elev_m)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="absolute bottom-[140px] 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">
|
||
<div class="flex flex-col gap-0.5">
|
||
<div :for={s <- @fixed_stations} class="flex items-center gap-2 py-1">
|
||
<label class="cursor-pointer flex items-center gap-2 min-w-0 shrink-0">
|
||
<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 truncate">{s.callsign}</span>
|
||
</label>
|
||
<form
|
||
phx-submit="update_station_grid"
|
||
phx-change="update"
|
||
class="flex-1 min-w-0"
|
||
id={"station-grid-form-" <> station_id(s)}
|
||
>
|
||
<input type="hidden" name="station_id" value={station_id(s)} />
|
||
<input
|
||
type="text"
|
||
name="grid"
|
||
value={s.grid || ""}
|
||
placeholder="grid"
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
phx-debounce="blur"
|
||
title="Click to edit; press Enter or click away to save"
|
||
class="font-mono text-[11px] w-full bg-transparent border-0 border-b border-transparent focus:border-base-content/30 focus:bg-base-100/30 focus:outline-none px-1 py-0 opacity-60 focus:opacity-100"
|
||
/>
|
||
</form>
|
||
<button
|
||
type="button"
|
||
class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100 shrink-0"
|
||
phx-click="delete_station"
|
||
phx-value-id={station_id(s)}
|
||
title="Remove"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<form phx-submit="add_station" phx-change="update" id="rover-form" class="flex gap-1 mt-2">
|
||
<input
|
||
type="text"
|
||
name="callsign"
|
||
placeholder="Call (grid auto-looked-up)"
|
||
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"
|
||
phx-hook="RoverSlider"
|
||
phx-update="ignore"
|
||
id="rover-forecast-slider"
|
||
data-label-id="rover-forecast-label"
|
||
data-format="forecast"
|
||
name="value"
|
||
class="range range-xs range-primary"
|
||
/>
|
||
<div class="text-xs opacity-70 mt-1">
|
||
<span id="rover-forecast-label">+{@forecast_hour}h</span>
|
||
</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 DISTANCE">
|
||
<input
|
||
type="range"
|
||
min="25"
|
||
max="250"
|
||
step="25"
|
||
value={@max_distance_mi}
|
||
phx-change="set_max_distance"
|
||
phx-debounce="200"
|
||
phx-hook="RoverSlider"
|
||
phx-update="ignore"
|
||
id="rover-distance-slider"
|
||
data-label-id="rover-distance-label"
|
||
data-format="distance"
|
||
name="value"
|
||
class="range range-xs range-primary"
|
||
/>
|
||
<div class="text-xs opacity-70 mt-1">
|
||
<span id="rover-distance-label">{@max_distance_mi} mi</span> from {@home.label}
|
||
</div>
|
||
</.sidebar_section>
|
||
|
||
<.sidebar_section title="CANDIDATES">
|
||
<label class="flex items-start gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={@only_good_locations}
|
||
phx-click="toggle_only_good"
|
||
class="checkbox checkbox-sm checkbox-primary mt-0.5"
|
||
/>
|
||
<span class="text-xs leading-tight">
|
||
Only use known good locations
|
||
<span class="block text-[10px] opacity-60 mt-0.5">
|
||
Restricts suggestions to spots tagged "good" in <a
|
||
href={~p"/rover-locations"}
|
||
class="link"
|
||
target="_blank"
|
||
>/rover-locations</a>.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</.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" phx-change="validate" id="home-form" 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
|
||
|
||
attr :detail, :map, required: true
|
||
|
||
defp candidate_detail_panel(assigns) do
|
||
~H"""
|
||
<div class="absolute right-4 bottom-[140px] z-20 w-[340px] max-h-[60vh] bg-base-100/95 backdrop-blur rounded-box shadow-lg border border-base-300 overflow-hidden flex flex-col">
|
||
<div class="flex items-center justify-between px-3 py-2 border-b border-base-300">
|
||
<div class="flex flex-col leading-tight min-w-0">
|
||
<span class="font-mono font-semibold text-sm truncate">{@detail.grid}</span>
|
||
<span class="text-[11px] opacity-60">
|
||
{km_to_mi(@detail.candidate.distance_km)} mi {@detail.candidate.bearing_compass} of home · {elev_ft(
|
||
@detail.rover_elev_m || @detail.candidate.elev_m
|
||
)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="btn btn-ghost btn-xs btn-square"
|
||
phx-click="close_candidate_detail"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex flex-col divide-y divide-base-300/60 overflow-y-auto">
|
||
<div :if={@detail.links == []} class="px-3 py-3 text-xs opacity-70">
|
||
No selected stations to summarize.
|
||
</div>
|
||
<div :for={link <- @detail.links} class="px-3 py-2">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<span class="font-mono text-sm font-semibold">{link.callsign}</span>
|
||
<span class="font-mono text-xs">
|
||
{format_margin(link.margin_db)} · {km_to_mi(link.distance_km)} mi
|
||
</span>
|
||
</div>
|
||
<div class="text-[11px] opacity-80 mt-0.5">
|
||
Aim <span class="font-mono">{round(link.bearing_deg)}°</span>
|
||
({link.bearing}) · clearance {clearance_label(link.clearance_m)}
|
||
</div>
|
||
<div class="text-[11px] opacity-60">
|
||
rover {elev_ft(link.rover_elev_m)} · max obstacle {elev_ft(link.max_obstacle_m)}
|
||
</div>
|
||
<.profile_svg :if={link.profile_svg} svg={link.profile_svg} callsign={link.callsign} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
attr :svg, :map, required: true
|
||
attr :callsign, :string, required: true
|
||
|
||
defp profile_svg(assigns) do
|
||
~H"""
|
||
<svg viewBox="0 0 240 64" class="w-full h-12 mt-1" preserveAspectRatio="none">
|
||
<polygon points={@svg.fill} fill="rgb(14 165 233 / 0.25)" />
|
||
<polygon
|
||
:if={@svg.canopy_fill}
|
||
points={@svg.canopy_fill}
|
||
fill="rgb(34 197 94 / 0.45)"
|
||
stroke="#16a34a"
|
||
stroke-width="0.4"
|
||
/>
|
||
<polygon
|
||
:if={@svg.building_fill}
|
||
points={@svg.building_fill}
|
||
fill="rgb(220 38 38 / 0.55)"
|
||
stroke="#dc2626"
|
||
stroke-width="0.6"
|
||
/>
|
||
<polyline points={@svg.line} fill="none" stroke="#0ea5e9" stroke-width="1.2" />
|
||
<line
|
||
x1="0"
|
||
y1={@svg.los_y_start}
|
||
x2="240"
|
||
y2={@svg.los_y_end}
|
||
stroke="#f59e0b"
|
||
stroke-width="1"
|
||
stroke-dasharray="3 2"
|
||
opacity="0.85"
|
||
/>
|
||
</svg>
|
||
<div class="flex justify-between text-[10px] opacity-60 font-mono gap-2">
|
||
<span>rover {elev_ft(@svg.start_elev_m)}</span>
|
||
<span class="opacity-70">{km_to_mi(@svg.total_km)} mi</span>
|
||
<span>{@callsign} {elev_ft(@svg.end_elev_m)}</span>
|
||
</div>
|
||
"""
|
||
end
|
||
end
|