defmodule MicrowavepropWeb.QsoLive.Show do @moduledoc false use MicrowavepropWeb, :live_view alias Microwaveprop.Radio alias Microwaveprop.Terrain 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) hrrr = Weather.hrrr_for_qso(qso) terrain = Terrain.get_terrain_profile(qso.id) {:ok, assign(socket, page_title: "#{qso.station1} / #{qso.station2}", qso: qso, surface_observations: weather.surface_observations, soundings: weather.soundings, solar: solar, hrrr: hrrr, terrain: terrain, terrain_expanded: false, hrrr_profile_expanded: false, 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_hrrr_profile", _params, socket) do {:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)} end def handle_event("toggle_terrain", _params, socket) do {:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)} 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""" <.header> {@qso.station1} / {@qso.station2} <:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")} <:actions> <.link navigate={~p"/qsos"} class="btn btn-sm btn-ghost"> <.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs

QSO Details

<.list> <:item title="Station 1">{@qso.station1} <:item title="Station 2">{@qso.station2} <:item title="Band">{@qso.band} MHz <:item title="Mode">{@qso.mode} <:item title="Grid 1">{@qso.grid1 || "—"} <:item title="Grid 2">{@qso.grid2 || "—"} <:item title="Distance">{@qso.distance_km || "—"} km <:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")} <:item :if={@qso.user_submitted} title="Source"> User Submitted <:item :if={@qso.user_submitted && @qso.submitter_email} title="Submitter"> {@qso.submitter_email}

Terrain Profile

<%= if @terrain do %>
<%= if @terrain_expanded do %>
<%= for {pt, idx} <- Enum.with_index(@terrain.path_points) do %> <% end %>
# Lat Lon Dist (km) Elev (m)
{idx} {format_number(pt["lat"])} {format_number(pt["lon"])} {format_number(pt["dist_km"])} {format_number(pt["elev"])}
<% end %>
<% else %>

No terrain profile available.

<% end %>

Soundings

<%= if @soundings == [] do %>

No soundings found nearby.

<% else %> <%= for s <- @soundings do %>
<%= if MapSet.member?(@expanded_soundings, s.id) do %>
<%= for level <- filter_profile(s.profile) do %> <% end %>
Pressure (mb) Height (m) Temp (°C) Dewpoint (°C) Wind Dir (°) Wind Spd (kts)
{format_number(level["pres"])} {format_number(level["hght"])} {format_number(level["tmpc"])} {format_number(level["dwpc"])} {format_number(level["drct"])} {format_number(level["sknt"])}
<% end %>
<% end %> <% end %>

Solar Conditions

<%= if @solar do %> <.list> <:item title="SFI">{@solar.sfi || "—"} <:item title="Sunspot Number">{@solar.sunspot_number || "—"} <:item title="Ap Index">{@solar.ap_index || "—"} <:item title="Kp Values">{format_kp(@solar.kp_values)} <% else %>

No solar data available for this date.

<% end %>

HRRR Model Profile

<%= if @hrrr do %>
<%= if @hrrr_profile_expanded do %>
Surface Temp: {format_number(@hrrr.surface_temp_c)}°C
Surface Dewpoint: {format_number(@hrrr.surface_dewpoint_c)}°C
Surface Pressure: {format_number(@hrrr.surface_pressure_mb)} mb
Run Time: {if @hrrr.run_time, do: Calendar.strftime(@hrrr.run_time, "%Y-%m-%d %H:%M UTC"), else: "—"}
<%= if @hrrr.profile && @hrrr.profile != [] do %>
<%= for level <- @hrrr.profile do %> <% end %>
Pressure (mb) Height (m) Temp (°C) Dewpoint (°C)
{format_number(level["pres"])} {format_number(level["hght"])} {format_number(level["tmpc"])} {format_number(level["dwpc"])}
<% end %>
<% end %>
<% else %>

No HRRR profile available.

<% end %>

Surface Observations

<%= if @surface_observations == [] do %>

No surface observations found nearby.

<% 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 :let={obs} label="Time" sort_field="observed_at"> {Calendar.strftime(obs.observed_at, "%H:%M")} <:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"} <:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f"> {obs.dewpoint_f || "—"} <:col :let={obs} label="RH%" sort_field="relative_humidity"> {format_number(obs.relative_humidity)} <:col :let={obs} label="Wind">{format_wind(obs)} <:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb"> {obs.sea_level_pressure_mb || "—"} <:col :let={obs} label="Sky">{obs.sky_condition || "—"} <% end %> """ 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) defp terrain_verdict_class("CLEAR"), do: "badge-success" defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge-warning" defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning" defp terrain_verdict_class("BLOCKED"), do: "badge-error" defp terrain_verdict_class(_), do: "badge-ghost" end