diff --git a/assets/js/elevation_profile_hook.ts b/assets/js/elevation_profile_hook.ts index 0d201410..53932ee4 100644 --- a/assets/js/elevation_profile_hook.ts +++ b/assets/js/elevation_profile_hook.ts @@ -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: { diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 21c7b909..5c07f0e1 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -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" > @@ -1120,13 +1152,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do <% else %>
- <%= if @contact.terrain_status == :queued do %> - - - Computing terrain profile {queue_info(@queue_counts, "terrain")} - - <% else %> - No terrain profile available. + <%= cond do %> + <% MapSet.member?(@hydration_pending, :terrain) -> %> + + Loading terrain profile… + + <% @contact.terrain_status == :queued -> %> + + + Computing terrain profile {queue_info(@queue_counts, "terrain")} + + <% true -> %> + No terrain profile available. <% end %>
<% end %> @@ -1136,13 +1173,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do

Soundings

<%= if @soundings == [] do %>
- <%= if @contact.weather_status == :queued do %> - - - Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")} - - <% else %> - No soundings found nearby. + <%= cond do %> + <% MapSet.member?(@hydration_pending, :weather) -> %> + + Loading soundings… + + <% @contact.weather_status == :queued -> %> + + + Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")} + + <% true -> %> + No soundings found nearby. <% end %>
<% else %> @@ -1335,18 +1377,22 @@ defmodule MicrowavepropWeb.ContactLive.Show do <% else %>
- <%= case @contact.hrrr_status do %> - <% :queued -> %> + <%= cond do %> + <% atmospheric_loading?(@hydration_pending) -> %> + + Loading atmospheric profile… + + <% @contact.hrrr_status == :queued -> %> Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")} - <% :unavailable -> %> + <% @contact.hrrr_status == :unavailable -> %> HRRR unavailable — fetching NARR reanalysis {queue_info(@queue_counts, "narr")} - <% _ -> %> + <% true -> %> No atmospheric profile available. <% end %>
@@ -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