Fix pos1 longitude key mismatch (lng vs lon) that caused empty weather results for all QSOs. Bump search radius to 300km for sounding coverage. Soundings now show as expandable cards with full elevation profile data.
292 lines
10 KiB
Elixir
292 lines
10 KiB
Elixir
defmodule MicrowavepropWeb.QsoLive.Show do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Weather
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
qso = Radio.get_qso!(id)
|
|
|
|
weather = load_weather(qso)
|
|
solar = load_solar(qso)
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "#{qso.station1} / #{qso.station2}",
|
|
qso: qso,
|
|
surface_observations: weather.surface_observations,
|
|
soundings: weather.soundings,
|
|
solar: solar,
|
|
obs_sort_by: "station_name",
|
|
obs_sort_order: "asc",
|
|
sounding_sort_by: "station_name",
|
|
sounding_sort_order: "asc",
|
|
expanded_soundings: MapSet.new()
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do
|
|
{sort_by, sort_order} =
|
|
toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field)
|
|
|
|
sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order)
|
|
|
|
{:noreply,
|
|
assign(socket,
|
|
surface_observations: sorted,
|
|
obs_sort_by: sort_by,
|
|
obs_sort_order: sort_order
|
|
)}
|
|
end
|
|
|
|
def handle_event("toggle_profile", %{"id" => id}, socket) do
|
|
expanded =
|
|
if MapSet.member?(socket.assigns.expanded_soundings, id) do
|
|
MapSet.delete(socket.assigns.expanded_soundings, id)
|
|
else
|
|
MapSet.put(socket.assigns.expanded_soundings, id)
|
|
end
|
|
|
|
{:noreply, assign(socket, expanded_soundings: expanded)}
|
|
end
|
|
|
|
def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do
|
|
{sort_by, sort_order} =
|
|
toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field)
|
|
|
|
sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order)
|
|
|
|
{:noreply,
|
|
assign(socket,
|
|
soundings: sorted,
|
|
sounding_sort_by: sort_by,
|
|
sounding_sort_order: sort_order
|
|
)}
|
|
end
|
|
|
|
defp toggle_sort(current_field, current_order, new_field) do
|
|
if current_field == new_field && current_order == "asc",
|
|
do: {new_field, "desc"},
|
|
else: {new_field, "asc"}
|
|
end
|
|
|
|
defp sort_observations(observations, sort_by, sort_order) do
|
|
sorter = obs_sort_key(sort_by)
|
|
sorted = Enum.sort_by(observations, sorter)
|
|
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
|
|
end
|
|
|
|
defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end
|
|
defp obs_sort_key("observed_at"), do: & &1.observed_at
|
|
defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0)
|
|
defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0)
|
|
defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0)
|
|
defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0)
|
|
defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end
|
|
|
|
defp sort_soundings(soundings, sort_by, sort_order) do
|
|
sorter = sounding_sort_key(sort_by)
|
|
sorted = Enum.sort_by(soundings, sorter)
|
|
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
|
|
end
|
|
|
|
defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end
|
|
|
|
defp sounding_sort_key("observed_at"), do: & &1.observed_at
|
|
defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0)
|
|
defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0)
|
|
defp sounding_sort_key("k_index"), do: &(&1.k_index || 0)
|
|
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
|
|
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
|
|
|
|
defp load_weather(qso) do
|
|
lat = qso.pos1 && qso.pos1["lat"]
|
|
lon = qso.pos1 && (qso.pos1["lon"] || qso.pos1["lng"])
|
|
|
|
if lat && lon do
|
|
Weather.weather_for_qso(%{lat: lat, lon: lon, timestamp: qso.qso_timestamp},
|
|
radius_km: 300
|
|
)
|
|
else
|
|
%{surface_observations: [], soundings: []}
|
|
end
|
|
end
|
|
|
|
defp load_solar(qso) do
|
|
qso.qso_timestamp
|
|
|> DateTime.to_date()
|
|
|> Weather.get_solar_index()
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash}>
|
|
<.header>
|
|
{@qso.station1} / {@qso.station2}
|
|
<:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
|
|
<:actions>
|
|
<.link navigate={~p"/qsos"} class="btn btn-sm btn-ghost">
|
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs
|
|
</.link>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">QSO Details</h2>
|
|
<.list>
|
|
<:item title="Station 1">{@qso.station1}</:item>
|
|
<:item title="Station 2">{@qso.station2}</:item>
|
|
<:item title="Band">{@qso.band} MHz</:item>
|
|
<:item title="Mode">{@qso.mode}</:item>
|
|
<:item title="Grid 1">{@qso.grid1 || "—"}</:item>
|
|
<:item title="Grid 2">{@qso.grid2 || "—"}</:item>
|
|
<:item title="Distance">{@qso.distance_km || "—"} km</:item>
|
|
<:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item>
|
|
</.list>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
|
|
<%= if @surface_observations == [] do %>
|
|
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
|
|
<% else %>
|
|
<.table
|
|
id="surface-obs"
|
|
rows={@surface_observations}
|
|
row_id={fn obs -> "obs-#{obs.id}" end}
|
|
sort_by={@obs_sort_by}
|
|
sort_order={@obs_sort_order}
|
|
sort_target="obs"
|
|
>
|
|
<:col :let={obs} label="Station" sort_field="station_name">
|
|
{obs.station.name || obs.station.station_code}
|
|
</:col>
|
|
<:col :let={obs} label="Time" sort_field="observed_at">
|
|
{Calendar.strftime(obs.observed_at, "%H:%M")}
|
|
</:col>
|
|
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"}</:col>
|
|
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
|
|
{obs.dewpoint_f || "—"}
|
|
</:col>
|
|
<:col :let={obs} label="RH%" sort_field="relative_humidity">
|
|
{format_number(obs.relative_humidity)}
|
|
</:col>
|
|
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
|
|
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
|
|
{obs.sea_level_pressure_mb || "—"}
|
|
</:col>
|
|
<:col :let={obs} label="Sky">{obs.sky_condition || "—"}</:col>
|
|
</.table>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">Soundings</h2>
|
|
<%= if @soundings == [] do %>
|
|
<p class="text-sm text-base-content/50 italic">No soundings found nearby.</p>
|
|
<% else %>
|
|
<%= for s <- @soundings do %>
|
|
<div class="mb-6 border border-base-300 rounded-lg">
|
|
<button
|
|
phx-click="toggle_profile"
|
|
phx-value-id={s.id}
|
|
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<span class="font-semibold">{s.station.name || s.station.station_code}</span>
|
|
<span>{Calendar.strftime(s.observed_at, "%H:%M UTC")}</span>
|
|
<span>{s.level_count} levels</span>
|
|
<span>Sfc: {format_number(s.surface_temp_c)}°C</span>
|
|
<span>N: {format_number(s.surface_refractivity)}</span>
|
|
<span>K: {format_number(s.k_index)}</span>
|
|
<span>LI: {format_number(s.lifted_index)}</span>
|
|
<%= if s.ducting_detected do %>
|
|
<span class="badge badge-warning badge-sm">Ducting</span>
|
|
<% end %>
|
|
</div>
|
|
<.icon
|
|
name={
|
|
if MapSet.member?(@expanded_soundings, s.id),
|
|
do: "hero-chevron-up",
|
|
else: "hero-chevron-down"
|
|
}
|
|
class="w-5 h-5"
|
|
/>
|
|
</button>
|
|
|
|
<%= if MapSet.member?(@expanded_soundings, s.id) do %>
|
|
<div class="px-4 pb-4">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-xs table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>Pressure (mb)</th>
|
|
<th>Height (m)</th>
|
|
<th>Temp (°C)</th>
|
|
<th>Dewpoint (°C)</th>
|
|
<th>Wind Dir (°)</th>
|
|
<th>Wind Spd (kts)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for level <- filter_profile(s.profile) do %>
|
|
<tr>
|
|
<td>{format_number(level["pres"])}</td>
|
|
<td>{format_number(level["hght"])}</td>
|
|
<td>{format_number(level["tmpc"])}</td>
|
|
<td>{format_number(level["dwpc"])}</td>
|
|
<td>{format_number(level["drct"])}</td>
|
|
<td>{format_number(level["sknt"])}</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
|
|
<%= if @solar do %>
|
|
<.list>
|
|
<:item title="SFI">{@solar.sfi || "—"}</:item>
|
|
<:item title="Sunspot Number">{@solar.sunspot_number || "—"}</:item>
|
|
<:item title="Ap Index">{@solar.ap_index || "—"}</:item>
|
|
<:item title="Kp Values">{format_kp(@solar.kp_values)}</:item>
|
|
</.list>
|
|
<% else %>
|
|
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
defp format_number(nil), do: "—"
|
|
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
|
|
defp format_number(n), do: to_string(n)
|
|
|
|
defp format_wind(obs) do
|
|
case {obs.wind_direction_deg, obs.wind_speed_kts} do
|
|
{nil, nil} -> "—"
|
|
{dir, speed} -> "#{dir || "?"}° @ #{speed || "?"} kts"
|
|
end
|
|
end
|
|
|
|
defp filter_profile(profile) do
|
|
Enum.filter(profile, fn level ->
|
|
level["tmpc"] != nil || level["dwpc"] != nil
|
|
end)
|
|
end
|
|
|
|
defp format_kp(nil), do: "—"
|
|
defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)
|
|
end
|