Adds a checkbox in the rover sidebar that constrains the suggested candidate spots to lat/lon points tagged as :ideal in /rover-locations. When enabled, Compute.run replaces grid cells with synthetic cells at each ideal location's exact coordinates, inheriting the propagation score from the nearest grid cell so atmospheric forecast still factors in. Path-clearance, terrain, building, and canopy penalties run unchanged on those snapped candidates.
1336 lines
44 KiB
Elixir
1336 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
|
||
|
||
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)
|
||
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_distance_mi: @default_max_distance_mi,
|
||
drive_radius_km: drive_radius_km(@default_max_distance_mi),
|
||
fixed_stations: fixed_stations,
|
||
persisted?: persisted?,
|
||
home: home,
|
||
valid_times: valid_times,
|
||
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_ideal_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, 18)
|
||
{: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_ideal", _params, socket) do
|
||
{:noreply, assign(socket, only_ideal_locations: not socket.assigns.only_ideal_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, _} ->
|
||
{: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_ideal_locations do
|
||
ideals =
|
||
Rover.list_locations()
|
||
|> Enum.filter(&(&1.status == :ideal))
|
||
|> Enum.map(fn l -> %{lat: l.lat, lon: l.lon} end)
|
||
|
||
Map.put(base, :ideal_locations, ideals)
|
||
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 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}) 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">
|
||
Ideal 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"
|
||
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" 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_ideal_locations}
|
||
phx-click="toggle_only_ideal"
|
||
class="checkbox checkbox-sm checkbox-primary mt-0.5"
|
||
/>
|
||
<span class="text-xs leading-tight">
|
||
Only use known ideal locations
|
||
<span class="block text-[10px] opacity-60 mt-0.5">
|
||
Restricts suggestions to spots tagged "ideal" 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" 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
|