817 lines
27 KiB
Elixir
817 lines
27 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.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,
|
||
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_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,
|
||
socket
|
||
|> assign(fixed_stations: stations, station_form_error: nil)
|
||
|> sync_url()}
|
||
|
||
{: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, socket |> assign(fixed_stations: stations) |> sync_url()}
|
||
|
||
{:error, _} ->
|
||
{:noreply, socket}
|
||
end
|
||
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 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, 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)
|
||
|
||
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}) 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: ""
|
||
|
||
# ── 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">
|
||
<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 flex-1 min-w-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 flex-1 truncate">{s.callsign}</span>
|
||
<span :if={s.grid} class="opacity-60 text-[11px]">{s.grid}</span>
|
||
</label>
|
||
<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"
|
||
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 DRIVE TIME">
|
||
<input
|
||
type="range"
|
||
min="30"
|
||
max="240"
|
||
step="15"
|
||
value={@max_drive_min}
|
||
phx-change="set_max_drive_time"
|
||
phx-debounce="200"
|
||
phx-hook="RoverSlider"
|
||
phx-update="ignore"
|
||
id="rover-drive-slider"
|
||
data-label-id="rover-drive-label"
|
||
data-format="drive"
|
||
name="value"
|
||
class="range range-xs range-primary"
|
||
/>
|
||
<div class="text-xs opacity-70 mt-1">
|
||
<span id="rover-drive-label">{Float.round(@max_drive_min / 60, 1)}h</span>
|
||
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
|