Python pskr_mqtt_listen.py: - Guard against malformed CONNACK/SUBACK/PUBLISH packets - Fix _s() truthiness: is not None instead of if v (zero SNR was lost) - EINTR-safe select loop, OOB data handling, VBInt overflow check - Proper socket cleanup with try/finally + DISCONNECT on shutdown Elixir: - Log dropped spot errors in aggregator (was silently discarded) - Wire retain_scores_window into NotifyListener chain completion - Recurse sweep_tmp_dir into band/weather_scalars subdirectories - Rescue recalibrator.run/0 to write failed status row on crash - Handle nil in fmt_snr/fmt_callsigns (no more nil dB crashes) - Replace Stream.run with Enum.reduce logging exits in poll_worker - Handle File.stat race in ms_footprints prune, grid_center nil log - Fix unused variables, stale comments, missing get_path!/1 Rust: - Graceful JoinError handling in fetcher/hrdps_fetcher (no more panics) - round_to_5min returns Option (leap-second safe) - Acquire/Release ordering on shutdown flags (was Relaxed) - Parameterized NOTIFY in db.rs, defensive scalar sweep, NaN guard - OsString::push for tmp naming, clippy fixes
428 lines
16 KiB
Elixir
428 lines
16 KiB
Elixir
defmodule MicrowavepropWeb.WeatherMapComponent do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :html
|
|
|
|
@group_order ["Surface", "Upper Air", "Ducting"]
|
|
|
|
attr :bands, :list, required: true
|
|
attr :selected, :any, default: nil
|
|
|
|
defp cutoff_band_picker(assigns) do
|
|
~H"""
|
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
<div class="text-[10px] font-semibold opacity-60 uppercase tracking-wider px-1">
|
|
Filter to band
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<button
|
|
phx-click="clear_band_filter"
|
|
class={[
|
|
"btn btn-xs rounded-full",
|
|
if(@selected == nil, do: "btn-primary", else: "btn-ghost")
|
|
]}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
:for={band <- @bands}
|
|
phx-click="select_band_filter"
|
|
phx-value-band={band}
|
|
class={[
|
|
"btn btn-xs rounded-full font-mono tabular-nums",
|
|
if(@selected == band, do: "btn-primary", else: "btn-ghost")
|
|
]}
|
|
>
|
|
{band}G
|
|
</button>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp group_layers(layers) do
|
|
layers
|
|
|> Enum.group_by(& &1.group)
|
|
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
|
|
end
|
|
|
|
defp layer_description(layers, selected_id) do
|
|
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
%{desc: desc} -> desc
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp selected_layer_label(layers, selected_id) do
|
|
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
%{label: label} -> label
|
|
_ -> ""
|
|
end
|
|
end
|
|
|
|
defp selected_layer_group_label(layers, selected_id) do
|
|
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
%{group: group} -> group
|
|
_ -> ""
|
|
end
|
|
end
|
|
|
|
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="flex w-screen h-screen overflow-hidden">
|
|
<%!-- Map container --%>
|
|
<div class="relative flex-1 min-w-0">
|
|
<%!-- Full-page map --%>
|
|
<div
|
|
id="weather-map"
|
|
phx-hook="WeatherMap"
|
|
phx-update="ignore"
|
|
data-layers={Jason.encode!(@layers)}
|
|
data-selected-layer={@selected_layer}
|
|
data-band-filter={@band_filter}
|
|
data-valid-times={@initial_valid_times_json}
|
|
data-selected-time={@initial_selected_time}
|
|
data-source={@data_source}
|
|
data-initial-lat={@initial_center_lat}
|
|
data-initial-lon={@initial_center_lon}
|
|
data-initial-zoom={@initial_zoom}
|
|
class="absolute inset-0 z-0"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Bottom forecast timeline --%>
|
|
<div
|
|
id="weather-forecast-timeline"
|
|
phx-update="ignore"
|
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Mobile-only floating controls --%>
|
|
<div
|
|
id="weather-controls"
|
|
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
|
>
|
|
<div
|
|
data-theme="dark"
|
|
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
|
|
>
|
|
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<span>NTMS</span>
|
|
<div class="font-normal text-xs opacity-70">{@page_subtitle}</div>
|
|
<div class="font-normal text-[10px] opacity-60">
|
|
<%= if @valid_time do %>
|
|
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
<% else %>
|
|
No data available
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 shrink-0">
|
|
<div
|
|
id="weather-utc-clock-mobile"
|
|
phx-hook="UtcClock"
|
|
phx-update="ignore"
|
|
class="font-mono text-xs opacity-70 tabular-nums"
|
|
>
|
|
{@initial_utc_clock}
|
|
</div>
|
|
<button
|
|
id="weather-mobile-panel-toggle"
|
|
class="btn btn-xs btn-square btn-ghost"
|
|
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
|
|
>
|
|
<.icon name="hero-bars-3" class="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="weather-panel-extras" class="hidden flex-col gap-2">
|
|
<%!-- Layer pill buttons grouped --%>
|
|
<div class="flex flex-col gap-1.5">
|
|
<div :for={{group, layers} <- group_layers(@layers)}>
|
|
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
|
{group}
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<button
|
|
:for={layer <- layers}
|
|
phx-click="select_layer"
|
|
phx-value-layer={layer.id}
|
|
class={[
|
|
"btn btn-xs rounded-full",
|
|
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
|
]}
|
|
>
|
|
{layer.label}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-[11px] opacity-70 px-1 leading-snug">
|
|
{layer_description(@layers, @selected_layer)}
|
|
</div>
|
|
|
|
<%= if @show_cutoff_bands do %>
|
|
<.cutoff_band_picker
|
|
:if={@selected_layer == "duct_cutoff_ghz"}
|
|
bands={@cutoff_bands}
|
|
selected={@band_filter}
|
|
/>
|
|
<% end %>
|
|
|
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary"
|
|
checked={@grid_visible}
|
|
phx-click="toggle_grid"
|
|
/>
|
|
<span class="text-sm">Grid squares</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary"
|
|
checked={@radar_visible}
|
|
phx-click="toggle_radar"
|
|
/>
|
|
<span class="text-sm">Weather radar</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
<%= if @show_other_weather_link do %>
|
|
<.link navigate={@other_weather_path} class="btn btn-xs btn-ghost justify-start">
|
|
{@other_weather_label}
|
|
</.link>
|
|
<% end %>
|
|
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
|
Propagation Map
|
|
</.link>
|
|
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
|
Path Calculator
|
|
</.link>
|
|
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
|
|
EME
|
|
</.link>
|
|
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
|
|
Beacons
|
|
</.link>
|
|
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
|
Submit a Contact
|
|
</.link>
|
|
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
|
Contact Training Data
|
|
</.link>
|
|
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
|
|
Contact Map
|
|
</.link>
|
|
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
|
Scoring Algorithm
|
|
</.link>
|
|
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
|
About
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Point detail panel --%>
|
|
<div
|
|
id="weather-detail-panel"
|
|
phx-update="ignore"
|
|
class={[
|
|
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
|
|
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
|
|
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
|
|
]}
|
|
style="display:none;"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
|
|
<button
|
|
id="weather-sidebar-expand"
|
|
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
|
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
|
|
>
|
|
<.icon name="hero-bars-3" class="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<%!-- Desktop right sidebar --%>
|
|
<div
|
|
id="weather-sidebar"
|
|
data-theme="dark"
|
|
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
|
>
|
|
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
|
|
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<span>NTMS</span>
|
|
<div class="font-normal text-xs opacity-70">{@page_subtitle}</div>
|
|
<div class="font-normal text-[10px] opacity-60">
|
|
<%= if @valid_time do %>
|
|
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
<% else %>
|
|
No data available
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 shrink-0">
|
|
<div
|
|
id="weather-utc-clock-desktop"
|
|
phx-hook="UtcClock"
|
|
phx-update="ignore"
|
|
class="font-mono text-xs opacity-70 tabular-nums"
|
|
>
|
|
{@initial_utc_clock}
|
|
</div>
|
|
<button
|
|
id="weather-sidebar-collapse"
|
|
class="btn btn-xs btn-square btn-ghost"
|
|
title="Collapse sidebar"
|
|
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
|
>
|
|
<.icon name="hero-chevron-right" class="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Layer pill buttons grouped --%>
|
|
<div class="flex flex-col gap-1.5">
|
|
<div :for={{group, layers} <- group_layers(@layers)}>
|
|
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
|
{group}
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<button
|
|
:for={layer <- layers}
|
|
phx-click="select_layer"
|
|
phx-value-layer={layer.id}
|
|
class={[
|
|
"btn btn-xs rounded-full",
|
|
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
|
]}
|
|
>
|
|
{layer.label}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Layer description (group · name header + body) --%>
|
|
<div class="px-1">
|
|
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
|
|
{selected_layer_group_label(@layers, @selected_layer)} · {selected_layer_label(
|
|
@layers,
|
|
@selected_layer
|
|
)}
|
|
</div>
|
|
<div class="text-[11px] opacity-70 leading-snug">
|
|
{layer_description(@layers, @selected_layer)}
|
|
</div>
|
|
</div>
|
|
|
|
<%= if @show_cutoff_bands do %>
|
|
<.cutoff_band_picker
|
|
:if={@selected_layer == "duct_cutoff_ghz"}
|
|
bands={@cutoff_bands}
|
|
selected={@band_filter}
|
|
/>
|
|
<% end %>
|
|
|
|
<%!-- Overlay toggles --%>
|
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary"
|
|
checked={@grid_visible}
|
|
phx-click="toggle_grid"
|
|
/>
|
|
<span class="text-sm">Grid squares</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary"
|
|
checked={@radar_visible}
|
|
phx-click="toggle_radar"
|
|
/>
|
|
<span class="text-sm">Weather radar</span>
|
|
</label>
|
|
</div>
|
|
|
|
<%!-- Navigation --%>
|
|
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
<%= if @show_other_weather_link do %>
|
|
<li>
|
|
<.link navigate={@other_weather_path}>{@other_weather_label}</.link>
|
|
</li>
|
|
<% end %>
|
|
<li>
|
|
<.link navigate="/map">Propagation Map</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/path">Path Calculator</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/eme">EME</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/beacons">Beacons</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/submit">Submit a Contact</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/contacts">Contact Training Data</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/contacts/map">Contact Map</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/algo">Scoring Algorithm</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/about">About</.link>
|
|
</li>
|
|
</ul>
|
|
|
|
<%!-- Auth --%>
|
|
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
<%= if @current_scope && @current_scope.user do %>
|
|
<li>
|
|
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
|
{@current_scope.user.callsign}
|
|
</.link>
|
|
</li>
|
|
<li>
|
|
<.link href={~p"/users/settings"}>Settings</.link>
|
|
</li>
|
|
<li>
|
|
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
|
</li>
|
|
<% else %>
|
|
<li>
|
|
<.link href={~p"/users/register"}>Register</.link>
|
|
</li>
|
|
<li>
|
|
<.link href={~p"/users/log-in"}>Log in</.link>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Layouts.flash_group flash={@flash} />
|
|
"""
|
|
end
|
|
end
|