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:
parent
35130a6c16
commit
5488744f44
2 changed files with 106 additions and 41 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue