feat(rover-planning): path detail redirects to /path with full UI

The cached path-detail URL was a thinned-down view (geometry +
loss-budget summary + score). The user wanted the same rich display
/path renders — terrain chart with feet axes + tooltips, conditions,
scoring, link/power budgets, forecast — so PathShow now resolves the
row's rover/station/band/heights and push_navigates to /path with
those params baked in. The cached result map on Path.result is still
what the rover-planning show table reads from for fast row rendering;
this just bridges the row click to the canonical Path Calculator.
This commit is contained in:
Graham McIntire 2026-05-03 14:37:57 -05:00
parent 69d0986dc9
commit 2851f467a3
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -1,11 +1,12 @@
defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
@moduledoc """
Renders a single rover-planning Path's cached `result` (terrain
analysis + baseline link budget + propagation score). Avoids the
live recompute that `/path` would do values come straight from
the row the worker stored. The "Open in Path Calculator" button
hands off to the live `/path` flow when the operator wants to see
the live HRRR-driven loss budget.
Stable permalink for one rover-planning Path. Resolves the saved
rover-location, station, band, and antenna heights into the full
`/path` URL so the operator sees the same terrain chart, conditions
readout, link / power budget, and forecast that the Path Calculator
renders. The cached `result` map on the Path row is still consulted
by the rover-planning show table this LiveView just bridges the
row click back to the canonical `/path` view.
"""
use MicrowavepropWeb, :live_view
@ -26,64 +27,16 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
|> push_navigate(to: ~p"/rover-planning/#{mission_id}")}
%Path{} = path ->
{:ok,
assign(socket,
page_title: "Path · #{path.mission.name}",
path: path,
mission: path.mission,
rover: path.rover_location,
station: path.station
)}
{:ok, push_navigate(socket, to: live_path_url(path))}
end
end
defp load_path(mission_id, path_id) do
with {:ok, mission_uuid} <- Ecto.UUID.cast(mission_id),
{:ok, path_uuid} <- Ecto.UUID.cast(path_id) do
Path
|> where([p], p.id == ^path_uuid and p.mission_id == ^mission_uuid)
|> preload([:rover_location, :station, :mission])
|> Repo.one()
else
_ -> nil
end
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) when is_integer(mhz), do: "#{mhz} MHz"
defp band_label(_), do: ""
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: ""
defp format_db(nil), do: ""
defp format_db(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} dB"
defp format_meters(nil), do: ""
defp format_meters(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} m"
defp verdict_label("CLEAR"), do: {"Clear", "badge badge-success"}
defp verdict_label("BLOCKED"), do: {"Blocked", "badge badge-error"}
defp verdict_label("FRESNEL_PARTIAL"), do: {"Fresnel partial", "badge badge-warning"}
defp verdict_label("FRESNEL_MINOR"), do: {"Fresnel minor", "badge badge-info"}
defp verdict_label(_), do: {"", "badge"}
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: ""
# Live /path link for when the user wants the HRRR-driven recompute
# rather than the cached snapshot we render here.
defp live_path_url(%Mission{} = mission, %Path{} = path, rover, station) do
# Builds the `/path` URL with the rover/station/band/heights baked
# in. Source uses a 10-char Maidenhead grid (max precision) so the
# rover spot resolves precisely; destination prefers the station's
# callsign, falling back to grid or coordinates.
defp live_path_url(%Path{} = path) do
%Path{rover_location: rover, station: station, mission: %Mission{} = mission} = path
src = Maidenhead.from_latlon(rover.lat, rover.lon, 10)
dst = station_endpoint(station)
band = path.band_mhz || mission.band_mhz
@ -100,112 +53,31 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
"/path?" <> query
end
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: ""
defp load_path(mission_id, path_id) do
with {:ok, mission_uuid} <- Ecto.UUID.cast(mission_id),
{:ok, path_uuid} <- Ecto.UUID.cast(path_id) do
Path
|> where([p], p.id == ^path_uuid and p.mission_id == ^mission_uuid)
|> preload([:rover_location, :station, :mission])
|> Repo.one()
else
_ -> nil
end
end
@impl true
def render(assigns) do
{verdict_text, verdict_class} =
verdict_label((assigns.path.result || %{})["verdict"])
assigns = assign(assigns, verdict_text: verdict_text, verdict_class: verdict_class)
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-4xl">
<.header>
Path profile
<:subtitle>
<span class="font-mono">{Maidenhead.from_latlon(@rover.lat, @rover.lon, 10)}</span>
<span class="font-mono">{station_endpoint(@station)}</span>
· {band_label(@path.band_mhz || @mission.band_mhz)}
<span class={"ml-2 #{@verdict_class}"}>{@verdict_text}</span>
</:subtitle>
<:actions>
<.link
navigate={~p"/rover-planning/#{@mission.id}"}
class="btn btn-ghost btn-sm"
>
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to mission
</.link>
<.link
href={live_path_url(@mission, @path, @rover, @station)}
class="btn btn-ghost btn-sm"
>
<.icon name="hero-arrow-top-right-on-square" class="w-4 h-4" /> Recompute live
</.link>
</:actions>
</.header>
<div :if={!@path.result} class="alert alert-warning">
Path is still {@path.status}. Try again in a moment.
</div>
<div :if={@path.result} class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="card bg-base-100 border border-base-300 p-4">
<h3 class="font-semibold mb-2">Geometry</h3>
<dl class="text-sm space-y-1">
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Distance</dt>
<dd class="font-mono">{format_distance(@path.result["distance_km"])}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Bearing</dt>
<dd class="font-mono">
{format_db(@path.result["bearing_deg"]) |> String.replace(" dB", "°")}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Min clearance</dt>
<dd class="font-mono">{format_meters(@path.result["min_clearance_m"])}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Max elevation</dt>
<dd class="font-mono">{format_meters(@path.result["max_elevation_m"])}</dd>
</div>
</dl>
</div>
<div class="card bg-base-100 border border-base-300 p-4">
<h3 class="font-semibold mb-2">Cached link budget</h3>
<dl class="text-sm space-y-1">
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Free-space loss</dt>
<dd class="font-mono">{format_db(@path.result["free_space_loss_db"])}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Oxygen absorption</dt>
<dd class="font-mono">{format_db(@path.result["oxygen_loss_db"])}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">HO (baseline 7.5 g/m³)</dt>
<dd class="font-mono">{format_db(@path.result["humidity_loss_db_baseline"])}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Diffraction</dt>
<dd class="font-mono">{format_db(@path.result["diffraction_db"])}</dd>
</div>
<div class="flex justify-between gap-2 font-semibold border-t border-base-300 pt-1 mt-1">
<dt>Total baseline</dt>
<dd class="font-mono">{format_db(@path.result["total_baseline_loss_db"])}</dd>
</div>
</dl>
<p class="text-xs text-base-content/60 mt-2">
Recompute live for HRRR-driven humidity / rain.
</p>
</div>
<div class="card bg-base-100 border border-base-300 p-4 md:col-span-2">
<h3 class="font-semibold mb-2">Propagation forecast</h3>
<p class="text-sm">
Cached score (midpoint, mission band):
<span class="font-mono ml-1">
{(@path.result["propagation_score"] || "") |> to_string()}
</span>
<span class="text-base-content/60 ml-1">/ 100</span>
</p>
<p class="text-xs text-base-content/60 mt-1">
Computed at {(@path.computed_at &&
Calendar.strftime(@path.computed_at, "%Y-%m-%d %H:%M UTC")) || ""}
</p>
</div>
</div>
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
<p class="text-sm text-base-content/60 p-8">Opening Path Calculator</p>
</Layouts.app>
"""
end