feat(contact): loading spinners, callsign-aware summaries, axis callsigns

Three improvements to the contact detail page:

* Track async hydration per source in `hydration_pending` so the
  Terrain / Soundings / Atmospheric Profile sections render a loading
  spinner while their task is in-flight instead of briefly flashing
  "no data available" before the payload arrives.
* Propagation analysis summary now names the origin station by
  callsign ("Path is terrain-obstructed at N mi from W5XD") instead
  of the generic "station 1" placeholder.
* Elevation profile chart labels the 0-mi and max-mi ticks with the
  matching callsign so it's obvious which end of the profile is
  which station.
This commit is contained in:
Graham McIntire 2026-04-18 13:43:34 -05:00
parent 35130a6c16
commit 5488744f44
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 106 additions and 41 deletions

View file

@ -168,6 +168,8 @@ export const ElevationProfile: ElevationProfileHook = {
const n = points.length - 1 const n = points.length - 1
const distances = points.map(p => p.dist_km * KM2MI) const distances = points.map(p => p.dist_km * KM2MI)
const showFresnel = data.freq_mhz >= 1000 const showFresnel = data.freq_mhz >= 1000
const station1 = this.el.dataset.station1 || ""
const station2 = this.el.dataset.station2 || ""
const k = data.k_factor || (4 / 3) const k = data.k_factor || (4 / 3)
const R = 6371000 const R = 6371000
@ -333,7 +335,18 @@ export const ElevationProfile: ElevationProfileHook = {
ticks: { ticks: {
font: { size: 9 }, font: { size: 9 },
autoSkip: false, autoSkip: false,
callback: (val: number) => distances[val] !== undefined ? Math.round(distances[val]) : "" // Chart.js renders string[] tick labels as stacked lines, so we
// tuck the station callsign onto the second line at the 0-mi
// and max-mi endpoints — gives each end of the profile a
// station identity without needing a separate axis.
callback: (val: number) => {
const mi = distances[val]
if (mi === undefined) return ""
const mileage = Math.round(mi)
if (val === 0 && station1) return [`${mileage}`, station1]
if (val === distances.length - 1 && station2) return [`${mileage}`, station2]
return `${mileage}`
}
} }
}, },
y: { y: {

View file

@ -63,6 +63,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
sounding_sort_order: "asc", sounding_sort_order: "asc",
queue_counts: fetch_queue_counts(), queue_counts: fetch_queue_counts(),
expanded_soundings: MapSet.new(), expanded_soundings: MapSet.new(),
hydration_pending: MapSet.new(),
editing: false, editing: false,
edit_form: nil, edit_form: nil,
pending_edit: load_pending_edit(contact, socket), pending_edit: load_pending_edit(contact, socket),
@ -90,6 +91,19 @@ defmodule MicrowavepropWeb.ContactLive.Show do
# clauses below update the matching slot and re-run the derived analysis. # clauses below update the matching slot and re-run the derived analysis.
defp kickoff_hydration(socket, contact) do defp kickoff_hydration(socket, contact) do
socket socket
|> assign(
:hydration_pending,
MapSet.new([
:weather,
:solar,
:hrrr_path,
:narr_path,
:native_profile,
:terrain,
:iemre,
:radar
])
)
|> start_async(:weather, fn -> load_weather(contact) end) |> start_async(:weather, fn -> load_weather(contact) end)
|> start_async(:solar, fn -> load_solar(contact) end) |> start_async(:solar, fn -> load_solar(contact) end)
|> start_async(:hrrr_path, fn -> Weather.hrrr_profiles_for_path(contact) end) |> start_async(:hrrr_path, fn -> Weather.hrrr_profiles_for_path(contact) end)
@ -100,6 +114,16 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|> start_async(:radar, fn -> load_radar(contact) end) |> start_async(:radar, fn -> load_radar(contact) end)
end end
defp mark_hydrated(socket, slot) do
assign(socket, :hydration_pending, MapSet.delete(socket.assigns.hydration_pending, slot))
end
defp atmospheric_loading?(pending) do
MapSet.member?(pending, :hrrr_path) or
MapSet.member?(pending, :narr_path) or
MapSet.member?(pending, :native_profile)
end
defp load_radar(contact) do defp load_radar(contact) do
import Ecto.Query import Ecto.Query
@ -336,6 +360,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
surface_observations: weather.surface_observations, surface_observations: weather.surface_observations,
soundings: weather.soundings soundings: weather.soundings
) )
|> mark_hydrated(:weather)
|> refresh_computed() |> refresh_computed()
{:noreply, socket} {:noreply, socket}
@ -350,7 +375,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
solar solar
end end
{:noreply, assign(socket, :solar, solar)} {:noreply, socket |> assign(:solar, solar) |> mark_hydrated(:solar)}
end end
def handle_async(:hrrr_path, {:ok, hrrr_path}, socket) do def handle_async(:hrrr_path, {:ok, hrrr_path}, socket) do
@ -363,6 +388,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
socket = socket =
socket socket
|> assign(contact: contact, hrrr: hrrr, hrrr_path: hrrr_path) |> assign(contact: contact, hrrr: hrrr, hrrr_path: hrrr_path)
|> mark_hydrated(:hrrr_path)
|> refresh_computed() |> refresh_computed()
{:noreply, socket} {:noreply, socket}
@ -375,7 +401,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do
contact = contact =
if socket.assigns.can_enqueue, do: maybe_enqueue_narr(narr, contact), else: contact if socket.assigns.can_enqueue, do: maybe_enqueue_narr(narr, contact), else: contact
{:noreply, assign(socket, contact: contact, narr: narr, narr_path: narr_path)} {:noreply,
socket
|> assign(contact: contact, narr: narr, narr_path: narr_path)
|> mark_hydrated(:narr_path)}
end end
def handle_async(:terrain, {:ok, terrain}, socket) do def handle_async(:terrain, {:ok, terrain}, socket) do
@ -387,27 +416,28 @@ defmodule MicrowavepropWeb.ContactLive.Show do
socket = socket =
socket socket
|> assign(contact: contact, terrain: terrain) |> assign(contact: contact, terrain: terrain)
|> mark_hydrated(:terrain)
|> refresh_computed() |> refresh_computed()
{:noreply, socket} {:noreply, socket}
end end
def handle_async(:iemre, {:ok, iemre}, socket) do def handle_async(:iemre, {:ok, iemre}, socket) do
{:noreply, assign(socket, :iemre, iemre)} {:noreply, socket |> assign(:iemre, iemre) |> mark_hydrated(:iemre)}
end end
def handle_async(:radar, {:ok, %{row: row, mechanism: mechanism}}, socket) do def handle_async(:radar, {:ok, %{row: row, mechanism: mechanism}}, socket) do
{:noreply, assign(socket, radar: row, mechanism: mechanism)} {:noreply, socket |> assign(radar: row, mechanism: mechanism) |> mark_hydrated(:radar)}
end end
def handle_async(:native_profile, {:ok, native_profile}, socket) do def handle_async(:native_profile, {:ok, native_profile}, socket) do
{:noreply, assign(socket, :native_profile, native_profile)} {:noreply, socket |> assign(:native_profile, native_profile) |> mark_hydrated(:native_profile)}
end end
def handle_async(slot, {:exit, reason}, socket) def handle_async(slot, {:exit, reason}, socket)
when slot in [:weather, :solar, :hrrr_path, :narr_path, :native_profile, :terrain, :iemre] do when slot in [:weather, :solar, :hrrr_path, :narr_path, :native_profile, :terrain, :iemre, :radar] do
Logger.warning("contact hydrate task #{slot} exited: #{inspect(reason)}") Logger.warning("contact hydrate task #{slot} exited: #{inspect(reason)}")
{:noreply, socket} {:noreply, mark_hydrated(socket, slot)}
end end
@impl true @impl true
@ -970,6 +1000,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
phx-hook="ElevationProfile" phx-hook="ElevationProfile"
phx-update="ignore" phx-update="ignore"
data-profile={Jason.encode!(@elevation_profile)} data-profile={Jason.encode!(@elevation_profile)}
data-station1={@contact.station1}
data-station2={@contact.station2}
class="w-full h-96 border border-base-300 rounded-lg" class="w-full h-96 border border-base-300 rounded-lg"
> >
<canvas></canvas> <canvas></canvas>
@ -1120,13 +1152,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</div> </div>
<% else %> <% else %>
<div class="text-sm text-base-content/50 italic"> <div class="text-sm text-base-content/50 italic">
<%= if @contact.terrain_status == :queued do %> <%= cond do %>
<span class="flex items-center gap-2"> <% MapSet.member?(@hydration_pending, :terrain) -> %>
<span class="loading loading-spinner loading-sm"></span> <span class="flex items-center gap-2">
Computing terrain profile {queue_info(@queue_counts, "terrain")} <span class="loading loading-spinner loading-sm"></span> Loading terrain profile
</span> </span>
<% else %> <% @contact.terrain_status == :queued -> %>
No terrain profile available. <span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Computing terrain profile {queue_info(@queue_counts, "terrain")}
</span>
<% true -> %>
No terrain profile available.
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
@ -1136,13 +1173,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<h2 class="text-base font-semibold mb-2">Soundings</h2> <h2 class="text-base font-semibold mb-2">Soundings</h2>
<%= if @soundings == [] do %> <%= if @soundings == [] do %>
<div class="text-sm text-base-content/50 italic"> <div class="text-sm text-base-content/50 italic">
<%= if @contact.weather_status == :queued do %> <%= cond do %>
<span class="flex items-center gap-2"> <% MapSet.member?(@hydration_pending, :weather) -> %>
<span class="loading loading-spinner loading-sm"></span> <span class="flex items-center gap-2">
Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")} <span class="loading loading-spinner loading-sm"></span> Loading soundings
</span> </span>
<% else %> <% @contact.weather_status == :queued -> %>
No soundings found nearby. <span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")}
</span>
<% true -> %>
No soundings found nearby.
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
@ -1335,18 +1377,22 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</div> </div>
<% else %> <% else %>
<div class="text-sm text-base-content/50 italic"> <div class="text-sm text-base-content/50 italic">
<%= case @contact.hrrr_status do %> <%= cond do %>
<% :queued -> %> <% atmospheric_loading?(@hydration_pending) -> %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span> Loading atmospheric profile
</span>
<% @contact.hrrr_status == :queued -> %>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")} Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")}
</span> </span>
<% :unavailable -> %> <% @contact.hrrr_status == :unavailable -> %>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
HRRR unavailable fetching NARR reanalysis {queue_info(@queue_counts, "narr")} HRRR unavailable fetching NARR reanalysis {queue_info(@queue_counts, "narr")}
</span> </span>
<% _ -> %> <% true -> %>
No atmospheric profile available. No atmospheric profile available.
<% end %> <% end %>
</div> </div>
@ -1934,7 +1980,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do
hrrr = List.first(hrrr_path) hrrr = List.first(hrrr_path)
{factors, factor_rows, composite} = compute_factors(hrrr_path, contact, band_config) {factors, factor_rows, composite} = compute_factors(hrrr_path, contact, band_config)
summary = build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz)
summary =
build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact)
details = build_details(hrrr, hrrr_path, soundings, elevation_profile, band_mhz) details = build_details(hrrr, hrrr_path, soundings, elevation_profile, band_mhz)
%{ %{
@ -1989,8 +2038,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
end end
end end
defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz) do defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact) do
terrain_status = terrain_summary(terrain, elevation_profile) terrain_status = terrain_summary(terrain, elevation_profile, contact)
mechanism = propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) mechanism = propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km)
band_note = band_summary(band_mhz, hrrr) band_note = band_summary(band_mhz, hrrr)
@ -1999,28 +2048,31 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|> Enum.join(" ") |> Enum.join(" ")
end end
defp terrain_summary(nil, nil), do: "No terrain data available." defp terrain_summary(nil, nil, _contact), do: "No terrain data available."
defp terrain_summary(terrain, elevation_profile) do defp terrain_summary(terrain, elevation_profile, contact) do
verdict = (elevation_profile && elevation_profile.verdict) || (terrain && terrain.verdict) verdict = (elevation_profile && elevation_profile.verdict) || (terrain && terrain.verdict)
describe_verdict(verdict, elevation_profile) describe_verdict(verdict, elevation_profile, contact)
end end
defp describe_verdict("CLEAR", _), do: "Line of sight is clear between stations." defp describe_verdict("CLEAR", _, _), do: "Line of sight is clear between stations."
defp describe_verdict("FRESNEL_MINOR", _), do: "Line of sight is clear but with minor Fresnel zone encroachment." defp describe_verdict("FRESNEL_MINOR", _, _), do: "Line of sight is clear but with minor Fresnel zone encroachment."
defp describe_verdict("FRESNEL_PARTIAL", _), do: "Line of sight has partial Fresnel zone obstruction." defp describe_verdict("FRESNEL_PARTIAL", _, _), do: "Line of sight has partial Fresnel zone obstruction."
defp describe_verdict("BLOCKED", elevation_profile) do defp describe_verdict("BLOCKED", elevation_profile, contact) do
obs_dist = elevation_profile && elevation_profile.first_obstruction_km obs_dist = elevation_profile && elevation_profile.first_obstruction_km
blocked_message(obs_dist) blocked_message(obs_dist, contact)
end end
defp describe_verdict(_, _), do: nil defp describe_verdict(_, _, _), do: nil
defp blocked_message(nil), do: "Path is terrain-obstructed." defp blocked_message(nil, _contact), do: "Path is terrain-obstructed."
defp blocked_message(obs_dist) do defp blocked_message(obs_dist, contact) do
"Path is terrain-obstructed at #{:erlang.float_to_binary(obs_dist * 0.621371, decimals: 1)} mi (#{:erlang.float_to_binary(obs_dist, decimals: 1)} km) from station 1." origin = contact && contact.station1
origin_label = if origin, do: origin, else: "station 1"
"Path is terrain-obstructed at #{:erlang.float_to_binary(obs_dist * 0.621371, decimals: 1)} mi (#{:erlang.float_to_binary(obs_dist, decimals: 1)} km) from #{origin_label}."
end end
defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do