feat(rover): full-bleed layout matching /map and /weather; remove Mode selector

This commit is contained in:
Graham McIntire 2026-04-25 16:26:19 -05:00
parent 7c506a6453
commit c13a5f53be
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 210 additions and 252 deletions

View file

@ -1,7 +1,7 @@
defmodule MicrowavepropWeb.RoverLive do
@moduledoc """
Rover-planning page: enter the fixed stations you want to work, pick a
band/mode/forecast hour, and the map paints a smoothed propagation
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.
"""
@ -10,7 +10,6 @@ defmodule MicrowavepropWeb.RoverLive do
alias Microwaveprop.Accounts.User
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ModeThresholds
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover
@ -119,10 +118,6 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply, assign(socket, band: band)}
end
def handle_event("select_mode", %{"mode" => mode_str}, socket) do
{:noreply, assign(socket, mode: parse_mode(mode_str, socket.assigns.mode))}
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)}
@ -471,28 +466,12 @@ defmodule MicrowavepropWeb.RoverLive do
end
end
defp parse_mode(mode_str, fallback) do
case mode_str do
"ssb" -> :ssb
"cw" -> :cw
"q65_60a" -> :q65_60a
"q65_120b" -> :q65_120b
_ -> fallback
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 mode_label(mode) do
Enum.find_value(ModeThresholds.modes(), "#{mode}", fn {atom, label} ->
if atom == mode, do: label
end)
end
defp station_id(%{id: id}), do: to_string(id)
defp station_id(_), do: nil
@ -501,244 +480,223 @@ defmodule MicrowavepropWeb.RoverLive do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-full">
<div class="flex flex-col h-[calc(100vh-3.5rem)] w-full">
<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)} · {mode_label(@mode)} · Forecast +{@forecast_hour}h
</span>
</div>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm"
disabled={@scoring_loading}
<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"
]}
>
<span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
{if @scoring_loading, do: "Calculating…", else: "Calculate"}
</button>
</header>
</div>
<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
: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 class="absolute bottom-[122px] left-4 z-20 bg-base-100/90 rounded-box shadow p-2 text-xs flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #16a34a; opacity: 0.5">
</span>
<span>Excellent 10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #eab308; opacity: 0.5">
</span>
<span>Good 3..10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #f97316; opacity: 0.5">
</span>
<span>Marginal 0..3 dB</span>
</div>
<div class="flex items-center gap-2 pt-1 border-t border-base-300/50">
<span class="w-4 border-t-2 border-dashed border-sky-500"></span>
<span>Drive radius</span>
</div>
</div>
</div>
<aside
id="rover-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-72 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-y-auto"
>
<.sidebar_section title="HOME">
<.home_form home={@home} error={@home_form_error} persisted?={@persisted?} />
</.sidebar_section>
<.sidebar_section title="FIXED STATIONS">
<ul class="menu menu-sm p-0 gap-0.5">
<li :for={s <- @fixed_stations}>
<label class="cursor-pointer flex items-center gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-xs"
checked={s.selected}
phx-click="toggle_station"
phx-value-id={station_id(s)}
/>
<span class="font-mono text-xs flex-1">{s.callsign}</span>
<span :if={s.grid} class="opacity-60 text-[11px]">{s.grid}</span>
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100"
phx-click="delete_station"
phx-value-id={station_id(s)}
title="Remove"
>
×
</button>
</label>
</li>
</ul>
<form phx-submit="add_station" class="flex gap-1 mt-2">
<input
type="text"
name="callsign"
placeholder="Call"
class="input input-bordered input-xs flex-1"
autocomplete="off"
/>
<input
type="text"
name="grid"
placeholder="Grid"
class="input input-bordered input-xs w-20"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs btn-primary">+</button>
</form>
<p :if={@station_form_error} class="text-error text-[11px] mt-1">
{@station_form_error}
</p>
<p :if={!@persisted?} class="text-[11px] opacity-50 mt-2">
Sign in to save your station list.
</p>
</.sidebar_section>
<.sidebar_section title="FORECAST HOUR">
<input
type="range"
min="0"
max="18"
step="1"
value={@forecast_hour}
phx-change="set_forecast_hour"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">+{@forecast_hour}h</div>
</.sidebar_section>
<.sidebar_section title="BAND">
<div class="join w-full">
<button
:for={b <- @bands}
type="button"
phx-click="select_band"
phx-value-band={b.value}
class={[
"btn btn-sm join-item flex-1",
@band == b.value && "btn-primary"
]}
>
{b.label |> String.replace(" GHz", "G")}
</button>
</div>
</.sidebar_section>
<.sidebar_section title="MODE">
<select
name="mode"
phx-change="select_mode"
class="select select-sm select-bordered w-full"
>
<option
:for={{atom, label} <- ModeThresholds.modes()}
value={Atom.to_string(atom)}
selected={atom == @mode}
>
{label}
</option>
</select>
</.sidebar_section>
<.sidebar_section title="MAX DRIVE TIME">
<input
type="range"
min="30"
max="240"
step="15"
value={@max_drive_min}
phx-change="set_max_drive_time"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">
{Float.round(@max_drive_min / 60, 1)}h from {@home.label}
</div>
</.sidebar_section>
<.sidebar_section title="MIN ELEV GAIN">
<input
type="range"
min="0"
max="500"
step="25"
value={@min_elev_gain}
phx-change="set_min_elev_gain"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">{@min_elev_gain} m above home</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 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>
</aside>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #eab308; opacity: 0.5"></span>
<span>Good 3..10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #f97316; opacity: 0.5"></span>
<span>Marginal 0..3 dB</span>
</div>
<div class="flex items-center gap-2 pt-1 border-t border-base-300/50">
<span class="w-4 border-t-2 border-dashed border-sky-500"></span>
<span>Drive radius</span>
</div>
</div>
</div>
<aside
id="rover-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-72 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-y-auto"
>
<.sidebar_section title="HOME">
<.home_form home={@home} error={@home_form_error} persisted?={@persisted?} />
</.sidebar_section>
<.sidebar_section title="FIXED STATIONS">
<ul class="menu menu-sm p-0 gap-0.5">
<li :for={s <- @fixed_stations}>
<label class="cursor-pointer flex items-center gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-xs"
checked={s.selected}
phx-click="toggle_station"
phx-value-id={station_id(s)}
/>
<span class="font-mono text-xs flex-1">{s.callsign}</span>
<span :if={s.grid} class="opacity-60 text-[11px]">{s.grid}</span>
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100"
phx-click="delete_station"
phx-value-id={station_id(s)}
title="Remove"
>
×
</button>
</label>
</li>
</ul>
<form phx-submit="add_station" class="flex gap-1 mt-2">
<input
type="text"
name="callsign"
placeholder="Call"
class="input input-bordered input-xs flex-1"
autocomplete="off"
/>
<input
type="text"
name="grid"
placeholder="Grid"
class="input input-bordered input-xs w-20"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs btn-primary">+</button>
</form>
<p :if={@station_form_error} class="text-error text-[11px] mt-1">
{@station_form_error}
</p>
<p :if={!@persisted?} class="text-[11px] opacity-50 mt-2">
Sign in to save your station list.
</p>
</.sidebar_section>
<.sidebar_section title="FORECAST HOUR">
<input
type="range"
min="0"
max="18"
step="1"
value={@forecast_hour}
phx-change="set_forecast_hour"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">+{@forecast_hour}h</div>
</.sidebar_section>
<.sidebar_section title="BAND">
<div class="join w-full">
<button
:for={b <- @bands}
type="button"
phx-click="select_band"
phx-value-band={b.value}
class={[
"btn btn-sm join-item flex-1",
@band == b.value && "btn-primary"
]}
>
{b.label |> String.replace(" GHz", "G")}
</button>
</div>
</.sidebar_section>
<.sidebar_section title="MAX DRIVE TIME">
<input
type="range"
min="30"
max="240"
step="15"
value={@max_drive_min}
phx-change="set_max_drive_time"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">
{Float.round(@max_drive_min / 60, 1)}h from {@home.label}
</div>
</.sidebar_section>
<.sidebar_section title="MIN ELEV GAIN">
<input
type="range"
min="0"
max="500"
step="25"
value={@min_elev_gain}
phx-change="set_min_elev_gain"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">{@min_elev_gain} m above home</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>
</Layouts.app>
</div>
"""
end

View file

@ -40,7 +40,7 @@ defmodule Microwaveprop.Rover.ComputeTest do
assert is_list(result.cells)
assert length(result.top_candidates) <= 5
assert length(result.top_candidates) > 0
refute result.top_candidates == []
# Sorted by score desc.
scores = Enum.map(result.top_candidates, & &1.score)

View file

@ -14,15 +14,15 @@ defmodule MicrowavepropWeb.RoverLiveTest do
assert html =~ "Calculate"
end
test "shows the right-docked sidebar with the six configured sections", %{conn: conn} do
test "shows the right-docked sidebar with the configured sections", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/rover")
assert html =~ ~s(id="rover-sidebar")
assert html =~ "FIXED STATIONS"
assert html =~ "FORECAST HOUR"
assert html =~ "BAND"
assert html =~ "MODE"
assert html =~ "MAX DRIVE TIME"
assert html =~ "MIN ELEV GAIN"
refute html =~ ">MODE<"
end
test "displays the three default NTMS stations", %{conn: conn} do