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 distances = points.map(p => p.dist_km * KM2MI)
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 R = 6371000
@ -333,7 +335,18 @@ export const ElevationProfile: ElevationProfileHook = {
ticks: {
font: { size: 9 },
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: {

View file

@ -63,6 +63,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
sounding_sort_order: "asc",
queue_counts: fetch_queue_counts(),
expanded_soundings: MapSet.new(),
hydration_pending: MapSet.new(),
editing: false,
edit_form: nil,
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.
defp kickoff_hydration(socket, contact) do
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(:solar, fn -> load_solar(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)
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
import Ecto.Query
@ -336,6 +360,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
surface_observations: weather.surface_observations,
soundings: weather.soundings
)
|> mark_hydrated(:weather)
|> refresh_computed()
{:noreply, socket}
@ -350,7 +375,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
solar
end
{:noreply, assign(socket, :solar, solar)}
{:noreply, socket |> assign(:solar, solar) |> mark_hydrated(:solar)}
end
def handle_async(:hrrr_path, {:ok, hrrr_path}, socket) do
@ -363,6 +388,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
socket =
socket
|> assign(contact: contact, hrrr: hrrr, hrrr_path: hrrr_path)
|> mark_hydrated(:hrrr_path)
|> refresh_computed()
{:noreply, socket}
@ -375,7 +401,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do
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
def handle_async(:terrain, {:ok, terrain}, socket) do
@ -387,27 +416,28 @@ defmodule MicrowavepropWeb.ContactLive.Show do
socket =
socket
|> assign(contact: contact, terrain: terrain)
|> mark_hydrated(:terrain)
|> refresh_computed()
{:noreply, socket}
end
def handle_async(:iemre, {:ok, iemre}, socket) do
{:noreply, assign(socket, :iemre, iemre)}
{:noreply, socket |> assign(:iemre, iemre) |> mark_hydrated(:iemre)}
end
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
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
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)}")
{:noreply, socket}
{:noreply, mark_hydrated(socket, slot)}
end
@impl true
@ -970,6 +1000,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
phx-hook="ElevationProfile"
phx-update="ignore"
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"
>
<canvas></canvas>
@ -1120,13 +1152,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</div>
<% else %>
<div class="text-sm text-base-content/50 italic">
<%= if @contact.terrain_status == :queued do %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Computing terrain profile {queue_info(@queue_counts, "terrain")}
</span>
<% else %>
No terrain profile available.
<%= cond do %>
<% MapSet.member?(@hydration_pending, :terrain) -> %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span> Loading terrain profile
</span>
<% @contact.terrain_status == :queued -> %>
<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 %>
</div>
<% end %>
@ -1136,13 +1173,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<h2 class="text-base font-semibold mb-2">Soundings</h2>
<%= if @soundings == [] do %>
<div class="text-sm text-base-content/50 italic">
<%= if @contact.weather_status == :queued do %>
<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>
<% else %>
No soundings found nearby.
<%= cond do %>
<% MapSet.member?(@hydration_pending, :weather) -> %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span> Loading soundings
</span>
<% @contact.weather_status == :queued -> %>
<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 %>
</div>
<% else %>
@ -1335,18 +1377,22 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</div>
<% else %>
<div class="text-sm text-base-content/50 italic">
<%= case @contact.hrrr_status do %>
<% :queued -> %>
<%= cond do %>
<% 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="loading loading-spinner loading-sm"></span>
Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")}
</span>
<% :unavailable -> %>
<% @contact.hrrr_status == :unavailable -> %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
HRRR unavailable fetching NARR reanalysis {queue_info(@queue_counts, "narr")}
</span>
<% _ -> %>
<% true -> %>
No atmospheric profile available.
<% end %>
</div>
@ -1934,7 +1980,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do
hrrr = List.first(hrrr_path)
{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)
%{
@ -1989,8 +2038,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
end
end
defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz) do
terrain_status = terrain_summary(terrain, elevation_profile)
defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact) do
terrain_status = terrain_summary(terrain, elevation_profile, contact)
mechanism = propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km)
band_note = band_summary(band_mhz, hrrr)
@ -1999,28 +2048,31 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|> Enum.join(" ")
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)
describe_verdict(verdict, elevation_profile)
describe_verdict(verdict, elevation_profile, contact)
end
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_PARTIAL", _), do: "Line of sight has partial Fresnel zone obstruction."
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_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
blocked_message(obs_dist)
blocked_message(obs_dist, contact)
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
"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."
defp blocked_message(obs_dist, contact) do
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
defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do