Path table now shows callsign on top with grid muted below, and the detail-only Min clearance / Diffraction columns are gone. The verdict badge matched lowercase 'clear/blocked/marginal' but the worker stores uppercase 'CLEAR/BLOCKED/FRESNEL_PARTIAL/FRESNEL_MINOR' from TerrainAnalysis, so every cell silently rendered empty. Updated the guard clauses to the actual upstream strings and added Fresnel variants.
493 lines
19 KiB
Elixir
493 lines
19 KiB
Elixir
defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
|
@moduledoc """
|
|
`/rover-planning/:id` mission detail. Renders the planned stations
|
|
and the matrix of computed (or pending) terrain path profiles from
|
|
every candidate rover-location.
|
|
"""
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.Rover
|
|
alias Microwaveprop.Rover.Location
|
|
alias Microwaveprop.RoverPlanning
|
|
alias Microwaveprop.RoverPlanning.Mission
|
|
alias MicrowavepropWeb.LocationResolver
|
|
alias Phoenix.LiveView.JS
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
case RoverPlanning.get_mission_with_paths(id) do
|
|
nil ->
|
|
{:ok,
|
|
socket
|
|
|> put_flash(:error, "Mission not found.")
|
|
|> push_navigate(to: ~p"/rover-planning")}
|
|
|
|
%Mission{} = mission ->
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
|
|
end
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Mission · #{mission.name}",
|
|
mission: mission,
|
|
paths: RoverPlanning.list_paths(mission),
|
|
rover_sites: RoverPlanning.candidate_rover_locations(mission),
|
|
rover_site_input: ""
|
|
)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:rover_path_updated, _path_id}, socket) do
|
|
{:noreply, assign(socket, paths: RoverPlanning.list_paths(socket.assigns.mission))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("rover_site_input_change", %{"input" => input}, socket) do
|
|
{:noreply, assign(socket, rover_site_input: input)}
|
|
end
|
|
|
|
def handle_event("add_rover_site", %{"input" => input}, socket) do
|
|
with {:ok, user} <- authenticated(socket),
|
|
{:ok, %{lat: lat, lon: lon}} <- LocationResolver.resolve(input),
|
|
attrs = %{lat: lat, lon: lon, status: :good, notes: String.trim(input)},
|
|
{:ok, _loc} <- Rover.create_location(user, attrs) do
|
|
{:noreply, refresh_after_site_change(socket, "Rover site added — recomputing paths.")}
|
|
else
|
|
{:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in to add rover sites.")}
|
|
:empty -> {:noreply, put_flash(socket, :error, "Enter a callsign, grid, or lat,lon.")}
|
|
{:error, msg} when is_binary(msg) -> {:noreply, put_flash(socket, :error, msg)}
|
|
{:error, %Ecto.Changeset{} = cs} -> {:noreply, put_flash(socket, :error, error_summary(cs))}
|
|
end
|
|
end
|
|
|
|
def handle_event("delete_rover_site", %{"id" => id}, socket) do
|
|
with {:ok, user} <- authenticated(socket),
|
|
{:ok, _} <- Rover.delete_location(user, id) do
|
|
{:noreply, refresh_after_site_change(socket, "Rover site removed — recomputing paths.")}
|
|
else
|
|
{:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in to remove rover sites.")}
|
|
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete rover sites you created.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("delete", _params, socket) do
|
|
with {:ok, user} <- authenticated(socket),
|
|
{:ok, _} <- RoverPlanning.delete_mission(user, socket.assigns.mission.id) do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Mission deleted.")
|
|
|> push_navigate(to: ~p"/rover-planning")}
|
|
else
|
|
{:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in required.")}
|
|
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
|
|
end
|
|
end
|
|
|
|
# Re-enqueues the mission's path matrix and reloads the show-page assigns
|
|
# that depend on the rover-site set.
|
|
defp refresh_after_site_change(socket, flash_message) do
|
|
mission = socket.assigns.mission
|
|
:ok = RoverPlanning.replace_mission_paths(mission)
|
|
|
|
socket
|
|
|> assign(
|
|
rover_sites: RoverPlanning.candidate_rover_locations(mission),
|
|
paths: RoverPlanning.list_paths(mission),
|
|
rover_site_input: ""
|
|
)
|
|
|> put_flash(:info, flash_message)
|
|
end
|
|
|
|
defp authenticated(%Phoenix.LiveView.Socket{assigns: %{current_scope: %{user: %User{} = user}}}), do: {:ok, user}
|
|
|
|
defp authenticated(_), do: {:error, :unauthenticated}
|
|
|
|
defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
|
|
|
|
defp can_modify?(%{current_scope: %{user: %User{id: id}}}, %Mission{user_id: id}) when not is_nil(id), do: true
|
|
|
|
defp can_modify?(_, _), do: false
|
|
|
|
defp can_delete_site?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
|
|
|
|
defp can_delete_site?(%{current_scope: %{user: %User{id: id}}}, %Location{user_id: id}) when not is_nil(id), do: true
|
|
|
|
defp can_delete_site?(_, _), do: false
|
|
|
|
defp error_summary(%Ecto.Changeset{errors: []}), do: "Could not save rover site."
|
|
|
|
defp error_summary(%Ecto.Changeset{errors: errors}),
|
|
do: Enum.map_join(errors, "; ", fn {field, {msg, _}} -> "#{field} #{msg}" end)
|
|
|
|
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
|
|
ghz = mhz / 1000
|
|
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
|
|
end
|
|
|
|
defp band_label(mhz), do: "#{mhz} MHz"
|
|
|
|
defp status_badge(:complete) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-success badge-sm">Complete</span>|
|
|
end
|
|
|
|
defp status_badge(:computing) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-info badge-sm">Computing</span>|
|
|
end
|
|
|
|
defp status_badge(:failed) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-error badge-sm">Failed</span>|
|
|
end
|
|
|
|
defp status_badge(_) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-ghost badge-sm">Pending</span>|
|
|
end
|
|
|
|
defp verdict_badge("CLEAR") do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-success badge-xs">Clear</span>|
|
|
end
|
|
|
|
defp verdict_badge("BLOCKED") do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-error badge-xs">Blocked</span>|
|
|
end
|
|
|
|
defp verdict_badge("FRESNEL_PARTIAL") do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-warning badge-xs">Fresnel partial</span>|
|
|
end
|
|
|
|
defp verdict_badge("FRESNEL_MINOR") do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-info badge-xs">Fresnel minor</span>|
|
|
end
|
|
|
|
defp verdict_badge(_), do: ""
|
|
|
|
defp progress_summary(paths) do
|
|
Enum.reduce(paths, %{total: 0, complete: 0, pending: 0, failed: 0}, &tally_path/2)
|
|
end
|
|
|
|
defp tally_path(%{status: :complete}, acc), do: %{acc | total: acc.total + 1, complete: acc.complete + 1}
|
|
|
|
defp tally_path(%{status: status}, acc) when status in [:pending, :computing],
|
|
do: %{acc | total: acc.total + 1, pending: acc.pending + 1}
|
|
|
|
defp tally_path(%{status: :failed}, acc), do: %{acc | total: acc.total + 1, failed: acc.failed + 1}
|
|
defp tally_path(_, acc), do: %{acc | total: acc.total + 1}
|
|
|
|
# Bucket paths by their rover-location and sort each bucket so stations
|
|
# appear in the order the user defined on the mission. The outer order
|
|
# is by rover-location lat (north → south) so the same rover location
|
|
# keeps the same slot across re-renders.
|
|
defp paths_by_rover_location(paths) do
|
|
paths
|
|
|> Enum.group_by(& &1.rover_location_id)
|
|
|> Enum.map(&group_to_pair/1)
|
|
|> Enum.sort_by(&group_lat/1, :desc)
|
|
end
|
|
|
|
defp group_to_pair({_id, [%{rover_location: location} | _] = group}) do
|
|
{location, Enum.sort_by(group, &station_position/1)}
|
|
end
|
|
|
|
defp group_to_pair({_id, []}), do: {nil, []}
|
|
|
|
defp station_position(%{station: %{position: pos}}) when is_integer(pos), do: pos
|
|
defp station_position(_), do: 0
|
|
|
|
defp group_lat({%{lat: lat}, _}) when is_number(lat), do: lat
|
|
defp group_lat(_), do: 0
|
|
|
|
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), and
|
|
# `Float.round/2` only accepts floats. Coerce with `* 1.0` everywhere
|
|
# we round a value that came out of a `:map` column.
|
|
defp format_distance(km) when is_number(km), do: "#{Float.round(km * 1.0, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
|
|
|
|
defp format_distance(_), do: "—"
|
|
|
|
# Two-line station identity for the path table. Returns
|
|
# `{primary, secondary}` — primary is the callsign (or grid when no
|
|
# callsign), secondary is a Maidenhead grid (stored or derived from
|
|
# lat/lon) when it adds info beyond `primary`.
|
|
defp station_lines(%{station: %{} = station}), do: station_lines_for(station)
|
|
defp station_lines(_), do: {"—", nil}
|
|
|
|
defp station_lines_for(%{callsign: c} = s) when is_binary(c) and c != "", do: {c, station_grid(s)}
|
|
|
|
defp station_lines_for(%{grid: g}) when is_binary(g) and g != "", do: {g, nil}
|
|
|
|
defp station_lines_for(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon),
|
|
do: {Maidenhead.from_latlon(lat, lon, 6), nil}
|
|
|
|
defp station_lines_for(_), do: {"—", nil}
|
|
|
|
defp station_grid(%{grid: g}) when is_binary(g) and g != "", do: g
|
|
|
|
defp station_grid(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon), do: Maidenhead.from_latlon(lat, lon, 6)
|
|
|
|
defp station_grid(_), do: nil
|
|
|
|
# Endpoint string used for /path?destination=…. Prefers callsign, then
|
|
# any stored or derived grid (PathLive's LocationResolver re-resolves
|
|
# both back to the same point), falling back to bare coordinates only
|
|
# when nothing else is available.
|
|
defp station_endpoint(%{callsign: c}) when is_binary(c) and c != "", do: c
|
|
defp station_endpoint(%{grid: g}) when is_binary(g) and g != "", do: g
|
|
|
|
defp station_endpoint(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon),
|
|
do: Maidenhead.from_latlon(lat, lon, 8)
|
|
|
|
defp station_endpoint(_), do: ""
|
|
|
|
# Builds the /path URL pre-filling source (rover-location, max-precision
|
|
# grid), destination (station endpoint), band, and rover/station heights
|
|
# so the user lands on a ready-to-compute Path Calculator screen.
|
|
defp path_url(_mission, %{rover_location: nil}), do: nil
|
|
defp path_url(_mission, %{station: nil}), do: nil
|
|
|
|
defp path_url(%Mission{} = mission, %{rover_location: %{lat: lat, lon: lon}, station: station})
|
|
when is_number(lat) and is_number(lon) do
|
|
src = Maidenhead.from_latlon(lat, lon, 10)
|
|
dst = station_endpoint(station)
|
|
|
|
query =
|
|
URI.encode_query(%{
|
|
"source" => src,
|
|
"destination" => dst,
|
|
"band" => Integer.to_string(mission.band_mhz),
|
|
"src_height_ft" => Float.to_string(mission.rover_height_ft * 1.0),
|
|
"dst_height_ft" => Float.to_string(mission.station_height_ft * 1.0)
|
|
})
|
|
|
|
"/path?" <> query
|
|
end
|
|
|
|
defp path_url(_, _), do: nil
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
summary = progress_summary(assigns.paths)
|
|
grouped = paths_by_rover_location(assigns.paths)
|
|
assigns = assign(assigns, summary: summary, grouped_paths: grouped)
|
|
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
|
<.header>
|
|
{@mission.name}
|
|
<:subtitle>
|
|
{band_label(@mission.band_mhz)} · {length(@mission.stations)} station(s) ·
|
|
rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
|
|
</:subtitle>
|
|
<:actions>
|
|
<.link navigate={~p"/rover-planning"} class="btn btn-ghost btn-sm">
|
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> All missions
|
|
</.link>
|
|
<.link
|
|
:if={can_modify?(assigns, @mission)}
|
|
navigate={~p"/rover-planning/#{@mission.id}/edit"}
|
|
class="btn btn-ghost btn-sm"
|
|
>
|
|
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
|
|
</.link>
|
|
<button
|
|
:if={can_modify?(assigns, @mission)}
|
|
type="button"
|
|
phx-click="delete"
|
|
data-confirm="Delete this mission?"
|
|
class="btn btn-ghost btn-sm text-error"
|
|
>
|
|
<.icon name="hero-trash" class="w-4 h-4" /> Delete
|
|
</button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
|
|
<h3 class="font-semibold mb-2">Stationary stations</h3>
|
|
<ul class="text-sm space-y-1">
|
|
<li :for={station <- @mission.stations} class="flex flex-wrap gap-2">
|
|
<span class="font-mono">{station.callsign || station.grid || station.input}</span>
|
|
<span :if={station.grid} class="text-base-content/60">grid {station.grid}</span>
|
|
<span class="text-base-content/60 font-mono">
|
|
{Float.round(station.lat, 4)}, {Float.round(station.lon, 4)}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<p :if={@mission.notes && @mission.notes != ""} class="mt-3 text-sm whitespace-pre-line">
|
|
{@mission.notes}
|
|
</p>
|
|
|
|
<p class="text-xs text-base-content/60 mt-3">
|
|
Scope: {if @mission.only_known_good,
|
|
do: "Known good locations only (status = good)",
|
|
else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || "—"}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
|
|
<div class="flex items-center justify-between mb-2 gap-2 flex-wrap">
|
|
<h3 class="font-semibold">Rover sites</h3>
|
|
<span class="text-xs text-base-content/60">
|
|
{length(@rover_sites)} site(s) — paths recompute on add/remove
|
|
</span>
|
|
</div>
|
|
|
|
<ul :if={@rover_sites != []} class="text-sm space-y-1 mb-3">
|
|
<li :for={site <- @rover_sites} class="flex flex-wrap items-center gap-2">
|
|
<span class="font-mono">{Maidenhead.from_latlon(site.lat, site.lon, 10)}</span>
|
|
<span class="text-base-content/60 font-mono text-xs">
|
|
({Float.round(site.lat, 4)}, {Float.round(site.lon, 4)})
|
|
</span>
|
|
<span :if={site.notes && site.notes != ""} class="text-base-content/60 text-xs">
|
|
· {site.notes}
|
|
</span>
|
|
<button
|
|
:if={can_delete_site?(assigns, site)}
|
|
type="button"
|
|
phx-click="delete_rover_site"
|
|
phx-value-id={site.id}
|
|
data-confirm="Remove this rover site? This affects every mission that uses it."
|
|
class="btn btn-ghost btn-xs text-error ml-auto"
|
|
title="Remove rover site"
|
|
>
|
|
<.icon name="hero-trash" class="w-4 h-4" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<p :if={@rover_sites == []} class="text-sm text-base-content/60 mb-3">
|
|
No rover sites match this mission's scope yet — add one below.
|
|
</p>
|
|
|
|
<%!--
|
|
The add form takes the same flexible input the station inputs use:
|
|
callsign, Maidenhead grid, or `lat, lon`. Sign-in required.
|
|
--%>
|
|
<form
|
|
:if={@current_scope && @current_scope.user}
|
|
phx-submit="add_rover_site"
|
|
phx-change="rover_site_input_change"
|
|
class="flex flex-wrap gap-2 items-stretch"
|
|
>
|
|
<input
|
|
type="text"
|
|
name="input"
|
|
value={@rover_site_input}
|
|
placeholder="Callsign, EM12kp, or 32.91, -97.06"
|
|
class="input input-sm flex-1 min-w-[14rem]"
|
|
/>
|
|
<button type="submit" class="btn btn-primary btn-sm" disabled={@rover_site_input == ""}>
|
|
<.icon name="hero-plus" class="w-4 h-4" /> Add rover site
|
|
</button>
|
|
</form>
|
|
|
|
<p :if={!(@current_scope && @current_scope.user)} class="text-xs text-base-content/60">
|
|
<.link navigate={~p"/users/log-in"} class="link">Sign in</.link>
|
|
to add or remove rover sites.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 border border-base-300 p-4">
|
|
<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
|
<h3 class="font-semibold">Path profiles</h3>
|
|
<div class="text-xs text-base-content/70">
|
|
{@summary.complete}/{@summary.total} complete
|
|
<span :if={@summary.pending > 0}>· {@summary.pending} pending</span>
|
|
<span :if={@summary.failed > 0} class="text-error">
|
|
· {@summary.failed} failed
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div :if={@paths == []} class="text-sm text-base-content/60 py-4 text-center">
|
|
No paths yet — they'll appear here as the workers run.
|
|
</div>
|
|
|
|
<div :if={@paths != []} class="space-y-4">
|
|
<div
|
|
:for={{location, group_paths} <- @grouped_paths}
|
|
data-rover-group
|
|
class="border border-base-300 rounded-lg overflow-hidden"
|
|
>
|
|
<div class="bg-base-200 px-3 py-2 flex flex-wrap items-center justify-between gap-2">
|
|
<div class="font-mono text-sm">
|
|
{rover_location_heading(location)}
|
|
<span
|
|
:if={location && location.notes && location.notes != ""}
|
|
class="text-base-content/60 text-xs"
|
|
>
|
|
· {location.notes}
|
|
</span>
|
|
</div>
|
|
<div class="text-xs text-base-content/70">
|
|
{group_progress(group_paths)}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Station</th>
|
|
<th>Status</th>
|
|
<th>Distance</th>
|
|
<th>Verdict</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
:for={path <- group_paths}
|
|
phx-click={path_url(@mission, path) && JS.navigate(path_url(@mission, path))}
|
|
class={[
|
|
path_url(@mission, path) && "cursor-pointer hover:bg-base-200"
|
|
]}
|
|
title={path_url(@mission, path) && "Open in Path Calculator"}
|
|
>
|
|
<td class="font-mono text-xs">
|
|
<% {primary, secondary} = station_lines(path) %>
|
|
<div>{primary}</div>
|
|
<div :if={secondary} class="opacity-60">{secondary}</div>
|
|
</td>
|
|
<td>{status_badge(path.status)}</td>
|
|
<td class="text-xs">
|
|
{if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
|
|
</td>
|
|
<td>
|
|
{if path.result, do: verdict_badge(path.result["verdict"]), else: "—"}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
defp rover_location_heading(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do
|
|
# 10-char grid is the maximum useful Maidenhead precision; anything
|
|
# finer is below the float lat/lon's resolution. The user wants the
|
|
# full precision, not a 6-char truncation.
|
|
grid = Maidenhead.from_latlon(lat, lon, 10)
|
|
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
|
|
end
|
|
|
|
defp rover_location_heading(_), do: "—"
|
|
|
|
defp group_progress(paths) do
|
|
total = length(paths)
|
|
complete = Enum.count(paths, &(&1.status == :complete))
|
|
"#{complete}/#{total} paths"
|
|
end
|
|
end
|