feat(rover-planning): group path profiles by rover location
The flat path table forced you to scan column 1 to mentally group rows when standing at a specific rover spot. Each rover location now gets its own card with a heading (grid + lat/lon + notes) and an inner station-by-station table, so the paths to the fixed stations from the spot you're at are read in one place. Sorted by latitude (north → south) so the same location keeps the same slot across re-renders. `rover_label/1` is removed (its content now lives in the group heading).
This commit is contained in:
parent
cff0c6775d
commit
a18586046d
2 changed files with 123 additions and 44 deletions
|
|
@ -124,6 +124,21 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
%{total: total, complete: complete, pending: pending, failed: failed}
|
%{total: total, complete: complete, pending: pending, failed: failed}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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(fn {_id, group} ->
|
||||||
|
location = (List.first(group) || %{}).rover_location
|
||||||
|
sorted = Enum.sort_by(group, &(&1.station && &1.station.position))
|
||||||
|
{location, sorted}
|
||||||
|
end)
|
||||||
|
|> Enum.sort_by(fn {loc, _} -> -((loc && loc.lat) || 0) end)
|
||||||
|
end
|
||||||
|
|
||||||
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), and
|
# 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
|
# `Float.round/2` only accepts floats. Coerce with `* 1.0` everywhere
|
||||||
# we round a value that came out of a `:map` column.
|
# we round a value that came out of a `:map` column.
|
||||||
|
|
@ -137,13 +152,6 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
defp format_meters(nil), do: "—"
|
defp format_meters(nil), do: "—"
|
||||||
defp format_meters(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} m"
|
defp format_meters(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} m"
|
||||||
|
|
||||||
defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do
|
|
||||||
grid = Maidenhead.from_latlon(lat, lon, 6)
|
|
||||||
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp rover_label(_), do: "—"
|
|
||||||
|
|
||||||
defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c
|
defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c
|
||||||
defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
|
defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
|
||||||
|
|
||||||
|
|
@ -155,7 +163,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
summary = progress_summary(assigns.paths)
|
summary = progress_summary(assigns.paths)
|
||||||
assigns = assign(assigns, :summary, summary)
|
grouped = paths_by_rover_location(assigns.paths)
|
||||||
|
assigns = assign(assigns, summary: summary, grouped_paths: grouped)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
||||||
|
|
@ -227,42 +236,76 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
No paths yet — they'll appear here as the workers run.
|
No paths yet — they'll appear here as the workers run.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@paths != []} class="overflow-x-auto">
|
<div :if={@paths != []} class="space-y-4">
|
||||||
<table class="table table-sm">
|
<div
|
||||||
<thead>
|
:for={{location, group_paths} <- @grouped_paths}
|
||||||
<tr>
|
data-rover-group
|
||||||
<th>Rover location</th>
|
class="border border-base-300 rounded-lg overflow-hidden"
|
||||||
<th>Station</th>
|
>
|
||||||
<th>Status</th>
|
<div class="bg-base-200 px-3 py-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<th>Distance</th>
|
<div class="font-mono text-sm">
|
||||||
<th>Min clearance</th>
|
{rover_location_heading(location)}
|
||||||
<th>Diffraction</th>
|
<span
|
||||||
<th>Verdict</th>
|
:if={location && location.notes && location.notes != ""}
|
||||||
</tr>
|
class="text-base-content/60 text-xs"
|
||||||
</thead>
|
>
|
||||||
<tbody>
|
· {location.notes}
|
||||||
<tr :for={path <- @paths}>
|
</span>
|
||||||
<td class="font-mono text-xs">{rover_label(path)}</td>
|
</div>
|
||||||
<td class="font-mono text-xs">{station_label(path)}</td>
|
<div class="text-xs text-base-content/70">
|
||||||
<td>{status_badge(path.status)}</td>
|
{group_progress(group_paths)}
|
||||||
<td class="text-xs">
|
</div>
|
||||||
{if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
|
</div>
|
||||||
</td>
|
|
||||||
<td class="text-xs">
|
<div class="overflow-x-auto">
|
||||||
{if path.result, do: format_meters(path.result["min_clearance_m"]), else: "—"}
|
<table class="table table-sm">
|
||||||
</td>
|
<thead>
|
||||||
<td class="text-xs">
|
<tr>
|
||||||
{if path.result, do: format_db(path.result["diffraction_db"]), else: "—"}
|
<th>Station</th>
|
||||||
</td>
|
<th>Status</th>
|
||||||
<td>
|
<th>Distance</th>
|
||||||
{if path.result, do: verdict_badge(path.result["verdict"]), else: "—"}
|
<th>Min clearance</th>
|
||||||
</td>
|
<th>Diffraction</th>
|
||||||
</tr>
|
<th>Verdict</th>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={path <- group_paths}>
|
||||||
|
<td class="font-mono text-xs">{station_label(path)}</td>
|
||||||
|
<td>{status_badge(path.status)}</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
{if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
{if path.result, do: format_meters(path.result["min_clearance_m"]), else: "—"}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
{if path.result, do: format_db(path.result["diffraction_db"]), else: "—"}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{if path.result, do: verdict_badge(path.result["verdict"]), else: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp rover_location_heading(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do
|
||||||
|
grid = Maidenhead.from_latlon(lat, lon, 6)
|
||||||
|
"#{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
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,11 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_mission(user, name) do
|
defp create_mission(user, name, opts \\ []) do
|
||||||
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
|
locations = Keyword.get(opts, :locations, [%{lat: 32.0, lon: -97.0, status: :good}])
|
||||||
|
stations = Keyword.get(opts, :stations, %{"0" => %{"input" => "EM12kp", "position" => 0}})
|
||||||
|
|
||||||
|
Enum.each(locations, fn attrs -> {:ok, _} = Rover.create_location(user, attrs) end)
|
||||||
|
|
||||||
{:ok, mission} =
|
{:ok, mission} =
|
||||||
RoverPlanning.create_mission(user, %{
|
RoverPlanning.create_mission(user, %{
|
||||||
|
|
@ -30,7 +33,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
"only_known_good" => true,
|
"only_known_good" => true,
|
||||||
"rover_height_ft" => 8.0,
|
"rover_height_ft" => 8.0,
|
||||||
"station_height_ft" => 30.0,
|
"station_height_ft" => 30.0,
|
||||||
"stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
|
"stations" => stations
|
||||||
})
|
})
|
||||||
|
|
||||||
mission
|
mission
|
||||||
|
|
@ -71,6 +74,39 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
assert html =~ "Path profiles"
|
assert html =~ "Path profiles"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "groups paths by rover location", %{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
|
||||||
|
mission =
|
||||||
|
create_mission(user, "Grouped mission",
|
||||||
|
locations: [
|
||||||
|
%{lat: 32.0, lon: -97.0, status: :good, notes: "Spot Alpha"},
|
||||||
|
%{lat: 33.0, lon: -98.0, status: :good, notes: "Spot Bravo"}
|
||||||
|
],
|
||||||
|
stations: %{
|
||||||
|
"0" => %{"input" => "EM12kp", "position" => 0},
|
||||||
|
"1" => %{"input" => "EM13qc", "position" => 1}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||||||
|
|
||||||
|
# One group container per rover location; both lat/lon labels appear
|
||||||
|
# as group headings (not inline in cells like the old flat table).
|
||||||
|
assert has_element?(lv, "[data-rover-group]")
|
||||||
|
assert html =~ "32.0"
|
||||||
|
assert html =~ "33.0"
|
||||||
|
|
||||||
|
# 2 locations × 2 stations = 4 path rows split into 2 groups.
|
||||||
|
group_count =
|
||||||
|
lv
|
||||||
|
|> render()
|
||||||
|
|> then(&Regex.scan(~r/data-rover-group/, &1))
|
||||||
|
|> length()
|
||||||
|
|
||||||
|
assert group_count == 2
|
||||||
|
end
|
||||||
|
|
||||||
test "renders integer path-result values without crashing", %{conn: conn} do
|
test "renders integer path-result values without crashing", %{conn: conn} do
|
||||||
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), so the
|
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), so the
|
||||||
# show page MUST tolerate non-float numerics in `result`. Previously
|
# show page MUST tolerate non-float numerics in `result`. Previously
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue