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}
|
||||
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
|
||||
# `Float.round/2` only accepts floats. Coerce with `* 1.0` everywhere
|
||||
# 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(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: %{grid: g}}) when is_binary(g) and g != "", do: g
|
||||
|
||||
|
|
@ -155,7 +163,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
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"""
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<div :if={@paths != []} class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rover location</th>
|
||||
<th>Station</th>
|
||||
<th>Status</th>
|
||||
<th>Distance</th>
|
||||
<th>Min clearance</th>
|
||||
<th>Diffraction</th>
|
||||
<th>Verdict</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={path <- @paths}>
|
||||
<td class="font-mono text-xs">{rover_label(path)}</td>
|
||||
<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 :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>Min clearance</th>
|
||||
<th>Diffraction</th>
|
||||
<th>Verdict</th>
|
||||
</tr>
|
||||
</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>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp create_mission(user, name) do
|
||||
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
|
||||
defp create_mission(user, name, opts \\ []) do
|
||||
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} =
|
||||
RoverPlanning.create_mission(user, %{
|
||||
|
|
@ -30,7 +33,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
|||
"only_known_good" => true,
|
||||
"rover_height_ft" => 8.0,
|
||||
"station_height_ft" => 30.0,
|
||||
"stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
|
||||
"stations" => stations
|
||||
})
|
||||
|
||||
mission
|
||||
|
|
@ -71,6 +74,39 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
|||
assert html =~ "Path profiles"
|
||||
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
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue