Three connected changes: 1) Extract PathLive.compute_path/4 + every helper it owned (resolve, profile grid lookup, sounding/ionosphere readouts, scoring, loss / power budgets) into Microwaveprop.Propagation.PathCompute. PathLive now delegates; ~410 lines of dead helpers deleted from PathLive. 2) RoverPathProfileWorker calls PathCompute.compute/4 with the mission's heights and PathLive's default station params (10 W TX, 30 dBi gains). Stores the full atom-keyed compute output as a Base64-encoded :erlang.term_to_binary/1 blob alongside the flat summary fields the rover-planning show table reads. The blob roundtrips structs and DateTime exactly (Jason.encode would lose them). 3) PathLive accepts ?rover_path_id=UUID. When set, loads the cached Path, decodes the term (binary_to_term :safe), assigns it as @result, and renders normally — no compute_path call. The rover-planning show table now links rows directly to /path?rover_path_id=UUID, so a click opens the full Path Calculator UI from cached data without re-running terrain / HRRR / sounding lookups. Bonus prod fixes folded in: - PathShow's elevation chart attribute (data-* → data-profile JSON) was crashing the JS hook with 'unexpected character at line 1'. - Station.changeset now wipes previously-resolved callsign/grid/ lat/lon when the user types into :input — typing 'AA5' early resolved to a wrong location and locked it; subsequent keystrokes never re-resolved. - phx-debounce=600 on the station input so QRZ doesn't get hit on every keystroke.
381 lines
12 KiB
Elixir
381 lines
12 KiB
Elixir
defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
|
@moduledoc "New / edit form for `/rover-planning` missions."
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
alias Microwaveprop.RoverPlanning
|
|
alias Microwaveprop.RoverPlanning.Mission
|
|
|
|
@band_options [
|
|
{"902 MHz", 902},
|
|
{"1.3 GHz", 1296},
|
|
{"2.3 GHz", 2304},
|
|
{"3.4 GHz", 3400},
|
|
{"5.7 GHz", 5760},
|
|
{"10 GHz", 10_000},
|
|
{"24 GHz", 24_000},
|
|
{"47 GHz", 47_000}
|
|
]
|
|
|
|
@impl true
|
|
def mount(params, _session, socket) do
|
|
case current_user(socket) do
|
|
nil ->
|
|
{:ok,
|
|
socket
|
|
|> put_flash(:error, "Sign in to plan a mission.")
|
|
|> push_navigate(to: ~p"/rover-planning")}
|
|
|
|
%User{} = user ->
|
|
{:ok,
|
|
socket
|
|
|> assign(
|
|
page_title: "Rover Mission",
|
|
band_options: @band_options,
|
|
current_user: user
|
|
)
|
|
|> apply_action(socket.assigns.live_action, params)}
|
|
end
|
|
end
|
|
|
|
defp apply_action(socket, :new, _params) do
|
|
# Seed the initial changeset PARAMS (not just the struct) with one
|
|
# station row so add_station / remove_station have something to work
|
|
# with. Without this seed, cast_assoc treats the first add_station
|
|
# click as a replacement of the unseeded default — visually a no-op.
|
|
mission = %Mission{}
|
|
cs = Mission.changeset(mission, %{"stations" => %{"0" => %{"position" => "0"}}})
|
|
|
|
assign(socket,
|
|
mission: mission,
|
|
form: to_form(cs),
|
|
mode: :new
|
|
)
|
|
end
|
|
|
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
|
user = socket.assigns.current_user
|
|
|
|
case RoverPlanning.get_mission(id) do
|
|
nil ->
|
|
socket
|
|
|> put_flash(:error, "Mission not found.")
|
|
|> push_navigate(to: ~p"/rover-planning")
|
|
|
|
mission ->
|
|
if can_modify?(user, mission) do
|
|
cs = Mission.changeset(mission, %{})
|
|
|
|
assign(socket,
|
|
mission: mission,
|
|
form: to_form(cs),
|
|
mode: :edit
|
|
)
|
|
else
|
|
socket
|
|
|> put_flash(:error, "You can only edit your own missions.")
|
|
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"mission" => params}, socket) do
|
|
cs =
|
|
socket.assigns.mission
|
|
|> Mission.changeset(params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, form: to_form(cs))}
|
|
end
|
|
|
|
def handle_event("add_station", _params, socket) do
|
|
params = current_params(socket)
|
|
stations = stations_params(socket, params)
|
|
next_index = next_station_index(stations)
|
|
|
|
# Don't include "input" in the new station's params — `used_input?/1`
|
|
# treats any field present in params as "used" and would render the
|
|
# `validate_required([:input])` error before the user has typed
|
|
# anything. Leaving the key out keeps the row pristine until edited.
|
|
stations =
|
|
Map.put(stations, Integer.to_string(next_index), %{
|
|
"position" => Integer.to_string(next_index)
|
|
})
|
|
|
|
cs =
|
|
socket.assigns.mission
|
|
|> Mission.changeset(Map.put(params, "stations", stations))
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, form: to_form(cs))}
|
|
end
|
|
|
|
def handle_event("remove_station", %{"index" => index}, socket) do
|
|
params = current_params(socket)
|
|
stations = socket |> stations_params(params) |> Map.delete(index)
|
|
|
|
cs =
|
|
socket.assigns.mission
|
|
|> Mission.changeset(Map.put(params, "stations", stations))
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, form: to_form(cs))}
|
|
end
|
|
|
|
def handle_event("save", %{"mission" => params}, socket) do
|
|
user = socket.assigns.current_user
|
|
|
|
result =
|
|
case socket.assigns.mode do
|
|
:new -> RoverPlanning.create_mission(user, params)
|
|
:edit -> RoverPlanning.update_mission(user, socket.assigns.mission.id, params)
|
|
end
|
|
|
|
case result do
|
|
{:ok, mission} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Mission saved — computing path profiles.")
|
|
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")}
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply, put_flash(socket, :error, "Mission not found.")}
|
|
|
|
{:error, %Ecto.Changeset{} = cs} ->
|
|
{:noreply, assign(socket, form: to_form(cs))}
|
|
end
|
|
end
|
|
|
|
# When the form changeset already carries a "stations" params map
|
|
# (every keystroke fires phx-change="validate" which builds one),
|
|
# trust it. Otherwise — e.g. the user clicked add/remove BEFORE
|
|
# touching any other input on an :edit-mode form — fall back to
|
|
# rebuilding the params from the persisted mission's stations so
|
|
# `cast_assoc(on_replace: :delete)` doesn't wipe every existing row.
|
|
defp stations_params(_socket, %{"stations" => stations}) when is_map(stations) and stations != %{}, do: stations
|
|
|
|
defp stations_params(socket, _params), do: stations_from_mission(socket.assigns.mission)
|
|
|
|
defp stations_from_mission(%Mission{stations: list}) when is_list(list) do
|
|
list
|
|
|> Enum.with_index()
|
|
|> Map.new(fn {s, i} ->
|
|
{Integer.to_string(i),
|
|
%{
|
|
"id" => s.id,
|
|
"input" => s.input || s.callsign || s.grid,
|
|
"callsign" => s.callsign,
|
|
"grid" => s.grid,
|
|
"lat" => s.lat,
|
|
"lon" => s.lon,
|
|
"position" => Integer.to_string(s.position || i)
|
|
}}
|
|
end)
|
|
end
|
|
|
|
defp stations_from_mission(_), do: %{}
|
|
|
|
defp current_params(socket) do
|
|
case socket.assigns.form.source do
|
|
%Ecto.Changeset{params: params} when is_map(params) -> params
|
|
_ -> %{}
|
|
end
|
|
end
|
|
|
|
defp next_station_index(stations) when map_size(stations) == 0, do: 0
|
|
|
|
defp next_station_index(stations) do
|
|
stations
|
|
|> Map.keys()
|
|
|> Enum.map(fn k ->
|
|
case Integer.parse(to_string(k)) do
|
|
{n, _} -> n
|
|
:error -> -1
|
|
end
|
|
end)
|
|
|> Enum.max()
|
|
|> Kernel.+(1)
|
|
end
|
|
|
|
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
|
|
case assigns[:current_scope] do
|
|
%{user: %User{} = user} -> user
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp can_modify?(%User{is_admin: true}, _), do: true
|
|
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
|
|
defp can_modify?(_, _), do: false
|
|
|
|
# Multi-checkbox values come from the form as a list of strings (or
|
|
# ints, depending on whether the changeset has cast them yet). Tolerate
|
|
# both, falling back to "not checked" for nil / empty bands lists.
|
|
defp band_checked?(values, target) when is_list(values) do
|
|
Enum.any?(values, fn
|
|
v when is_integer(v) -> v == target
|
|
v when is_binary(v) -> v == Integer.to_string(target)
|
|
_ -> false
|
|
end)
|
|
end
|
|
|
|
defp band_checked?(_, _), do: false
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
|
|
<.header>
|
|
{if @mode == :edit, do: "Edit mission", else: "New rover mission"}
|
|
<:subtitle>
|
|
Stationary stations can be callsigns, Maidenhead grids, or <code>lat, lon</code> pairs.
|
|
</:subtitle>
|
|
</.header>
|
|
|
|
<.form
|
|
for={@form}
|
|
id="mission-form"
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
class="space-y-4"
|
|
>
|
|
<.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" />
|
|
|
|
<div>
|
|
<label class="label">
|
|
<span class="label-text">Bands</span>
|
|
<span class="label-text-alt text-base-content/60">
|
|
Pick one or more — paths recompute per band
|
|
</span>
|
|
</label>
|
|
<%!--
|
|
Hidden zero-value field so an unchecked-everywhere submit
|
|
still parses as an empty list (Phoenix drops the key
|
|
otherwise). Mission.changeset rejects an empty bands_mhz
|
|
with a friendly error.
|
|
--%>
|
|
<input type="hidden" name="mission[bands_mhz][]" value="" />
|
|
<div class="flex flex-wrap gap-2">
|
|
<label
|
|
:for={{label, value} <- @band_options}
|
|
class="cursor-pointer flex items-center gap-2 border border-base-300 rounded px-3 py-1 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
name="mission[bands_mhz][]"
|
|
value={value}
|
|
checked={band_checked?(@form[:bands_mhz].value, value)}
|
|
class="checkbox checkbox-sm checkbox-primary"
|
|
/>
|
|
<span>{label}</span>
|
|
</label>
|
|
</div>
|
|
<p :if={@form[:bands_mhz].errors != []} class="text-error text-sm mt-1">
|
|
{Phoenix.HTML.html_escape(Enum.map_join(@form[:bands_mhz].errors, ", ", &elem(&1, 0)))}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<.input
|
|
field={@form[:rover_height_ft]}
|
|
type="number"
|
|
step="any"
|
|
label="Rover antenna (ft)"
|
|
/>
|
|
<.input
|
|
field={@form[:station_height_ft]}
|
|
type="number"
|
|
step="any"
|
|
label="Station antenna (ft)"
|
|
/>
|
|
</div>
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="hidden" name="mission[only_known_good]" value="false" />
|
|
<input
|
|
type="checkbox"
|
|
name="mission[only_known_good]"
|
|
value="true"
|
|
checked={Phoenix.HTML.Form.normalize_value("checkbox", @form[:only_known_good].value)}
|
|
class="checkbox checkbox-primary"
|
|
/>
|
|
<span>
|
|
Only check against known good locations
|
|
<span class="text-xs opacity-70 block">
|
|
When checked, paths are computed only against rover-locations marked Good.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="font-semibold">Stationary stations</h3>
|
|
<button
|
|
type="button"
|
|
phx-click="add_station"
|
|
class="btn btn-xs btn-ghost"
|
|
>
|
|
<.icon name="hero-plus" class="w-4 h-4" /> Add station
|
|
</button>
|
|
</div>
|
|
|
|
<.inputs_for :let={s} field={@form[:stations]}>
|
|
<div class="card bg-base-200 p-3 mb-2">
|
|
<%!--
|
|
`items-start` keeps the lat/lon row top-aligned even when the
|
|
callsign cell grows (e.g. an error message under the input).
|
|
The trash button uses `mt-6` to sit beside the input — matches
|
|
the label height so it visually aligns with the input field.
|
|
--%>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 items-start">
|
|
<.input
|
|
field={s[:input]}
|
|
type="text"
|
|
label="Callsign / grid / lat,lon"
|
|
placeholder="W5LUA or EM13qc or 32.91, -97.06"
|
|
phx-debounce="600"
|
|
/>
|
|
<div class="flex items-start gap-2">
|
|
<div class="flex-1">
|
|
<.input field={s[:lat]} type="number" step="any" label="Lat" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<.input field={s[:lon]} type="number" step="any" label="Lon" />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
phx-click="remove_station"
|
|
phx-value-index={s.index}
|
|
class="btn btn-ghost btn-sm text-error mt-6"
|
|
title="Remove station"
|
|
>
|
|
<.icon name="hero-trash" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p :if={s[:callsign].value} class="text-xs opacity-70 mt-1 px-1">
|
|
Resolved callsign: {s[:callsign].value}
|
|
<span :if={s[:grid].value}>· grid {s[:grid].value}</span>
|
|
</p>
|
|
</div>
|
|
</.inputs_for>
|
|
|
|
<p :if={@form[:stations].errors != []} class="text-error text-sm">
|
|
{Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}
|
|
</p>
|
|
</div>
|
|
|
|
<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
|
|
|
|
<div class="flex gap-2">
|
|
<button type="submit" class="btn btn-primary">
|
|
{if @mode == :edit, do: "Save changes", else: "Create mission"}
|
|
</button>
|
|
<.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel</.link>
|
|
</div>
|
|
</.form>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|