feat(rover): full-bleed layout matching /map and /weather; remove Mode selector
This commit is contained in:
parent
7c506a6453
commit
c13a5f53be
3 changed files with 210 additions and 252 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue