Profile page:
* New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
page showing a user's contacts and beacons. Resolves case-
insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
callsigns redirect to /. Uses daisyUI card / stats / table
components with an avatar-placeholder initial and hero icons.
* Accounts.get_user_by_callsign/1 (case-insensitive) plus
Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
back the page. Beacons list includes both approved and pending so
owners see their drafts.
* The top nav bar and the three LiveView sidebars (MapLive,
WeatherMapLive, ContactMapLive) now render the logged-in callsign
as a navigate link to /u/:callsign instead of a static label.
* Nine new tests cover the lookup, the LiveView render, and the
ownership-scoped queries.
Flexible band input:
* New Microwaveprop.Radio.BandResolver module converts any of:
ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
insensitive), numeric frequency strings ("903.100", "10368.000"),
and canonical MHz integers into the one of the site's known bands.
Returns the nearest allowed band for numeric inputs >= 900 MHz,
nil otherwise.
* 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
AdifImport.@allowed_bands, and the BandResolver list so "33cm"
round-trips end-to-end.
* AdifImport and CsvImport now delegate band resolution to
BandResolver, and Radio.create_contact/2 normalizes the :band attr
on the way in so the manual form and any API callers benefit too.
CsvImport's "invalid band" tests previously used 99999 MHz which
the new resolver snaps to the nearest allowed band; swapped to
"notaband" which is truly unresolvable.
Contacts and beacons list UX:
* Remove the "Submitted" column from /contacts — it duplicated info
already visible on the detail page and was pushing the real
columns off narrow viewports. submitted_cell/1 and its three
column-specific tests go with it.
* Hide the Lat / Lon columns from /beacons — six decimal places of
coordinates weren't useful next to the grid square and took a
disproportionate amount of row width.
476 lines
16 KiB
Elixir
476 lines
16 KiB
Elixir
defmodule MicrowavepropWeb.WeatherMapLive do
|
||
@moduledoc false
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
alias Microwaveprop.Weather
|
||
|
||
@initial_bounds %{
|
||
"south" => 29.5,
|
||
"north" => 36.3,
|
||
"west" => -101.5,
|
||
"east" => -92.5
|
||
}
|
||
|
||
@layers [
|
||
# Surface
|
||
%{
|
||
id: "temperature",
|
||
label: "Temperature",
|
||
unit: "°C",
|
||
group: "Surface",
|
||
desc: "2m air temperature. Warm, humid air increases refractivity and ducting potential at lower frequencies."
|
||
},
|
||
%{
|
||
id: "dewpoint_depression",
|
||
label: "Td Depression",
|
||
unit: "°C",
|
||
group: "Surface",
|
||
desc:
|
||
"Temperature minus dewpoint. Small values (< 5°C) mean moist boundary layer — favorable for ducting at 10 GHz, but increases absorption above 24 GHz."
|
||
},
|
||
%{
|
||
id: "surface_rh",
|
||
label: "Humidity",
|
||
unit: "%",
|
||
group: "Surface",
|
||
desc:
|
||
"Relative humidity from surface T and Td. High RH supports refractivity gradients that bend microwave signals."
|
||
},
|
||
%{
|
||
id: "pwat",
|
||
label: "PWAT",
|
||
unit: "mm",
|
||
group: "Surface",
|
||
desc:
|
||
"Precipitable water — total moisture in the atmospheric column. High values signal rain fade risk for 24 GHz and above."
|
||
},
|
||
%{
|
||
id: "surface_refractivity",
|
||
label: "Refractivity",
|
||
unit: "N",
|
||
group: "Surface",
|
||
desc:
|
||
"Radio refractivity (N-units) at the surface. Typical values 280–380. Higher N means the atmosphere bends signals more toward the ground."
|
||
},
|
||
%{
|
||
id: "refractivity_gradient",
|
||
label: "N-Gradient",
|
||
unit: "N/km",
|
||
group: "Surface",
|
||
desc:
|
||
"Minimum refractivity gradient in the profile. Standard is −40 N/km. Below −157 N/km signals are trapped in a duct. More negative = stronger ducting."
|
||
},
|
||
%{
|
||
id: "bl_height",
|
||
label: "BL Height",
|
||
unit: "m",
|
||
group: "Surface",
|
||
desc:
|
||
"Planetary boundary layer height. Shallow BL (< 500m) concentrates moisture and heat near the surface, favoring temperature inversions and ducting."
|
||
},
|
||
# Upper Air
|
||
%{
|
||
id: "temp_850mb",
|
||
label: "T @ 850mb",
|
||
unit: "°C",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Temperature at 850 mb (~1500m altitude). Warm 850mb air over cool surface air indicates a capping inversion — a classic ducting setup."
|
||
},
|
||
%{
|
||
id: "dewpoint_850mb",
|
||
label: "Td @ 850mb",
|
||
unit: "°C",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Dewpoint at 850 mb. A sharp moisture drop between the surface and 850mb creates an elevated refractivity gradient that can form ducts."
|
||
},
|
||
%{
|
||
id: "lapse_rate",
|
||
label: "Lapse Rate",
|
||
unit: "°C/km",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Temperature decrease per km from surface to 700mb. Low rates (< 5 °C/km) mean stable air that preserves inversions. High rates (> 8) mean convective mixing that destroys them."
|
||
},
|
||
%{
|
||
id: "inversion_strength",
|
||
label: "Inversion",
|
||
unit: "°C",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Strongest temperature increase between adjacent levels. Inversions trap microwave signals. > 3°C is significant, > 5°C is strong."
|
||
},
|
||
%{
|
||
id: "inversion_base_m",
|
||
label: "Inv. Base",
|
||
unit: "m",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Height (m AGL) where the strongest inversion begins. Surface-based inversions (< 200m) create surface ducts. Elevated inversions create elevated ducts."
|
||
},
|
||
# Ducting
|
||
%{
|
||
id: "ducting",
|
||
label: "Ducting",
|
||
unit: "",
|
||
group: "Ducting",
|
||
desc:
|
||
"Whether a trapping layer (modified refractivity decreasing with height) was detected in the profile. Green = duct present, signals can travel far beyond line of sight."
|
||
},
|
||
%{
|
||
id: "duct_base_m",
|
||
label: "Duct Base",
|
||
unit: "m",
|
||
group: "Ducting",
|
||
desc:
|
||
"Height of the lowest duct base. Surface ducts (< 100m) are most effective for ground-based stations. Elevated ducts require antennas near the duct height."
|
||
},
|
||
%{
|
||
id: "duct_strength",
|
||
label: "Duct Strength",
|
||
unit: "M",
|
||
group: "Ducting",
|
||
desc:
|
||
"Duct trapping strength in modified refractivity (M) units. > 10 M is moderate, > 20 M is strong. Stronger ducts trap a wider range of frequencies."
|
||
}
|
||
]
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
if connected?(socket) do
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
|
||
end
|
||
|
||
data = Weather.latest_weather_grid(@initial_bounds)
|
||
valid_time = if data != [], do: hd(data).valid_time
|
||
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Weather Map",
|
||
layers: @layers,
|
||
selected_layer: "refractivity_gradient",
|
||
initial_data_json: Jason.encode!(data),
|
||
valid_time: valid_time,
|
||
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
|
||
bounds: @initial_bounds
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
|
||
{:noreply, assign(socket, :selected_layer, layer_id)}
|
||
end
|
||
|
||
def handle_event("map_bounds", bounds, socket) do
|
||
data = Weather.latest_weather_grid(bounds)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:bounds, bounds)
|
||
|> push_event("update_weather", %{data: data})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
|
||
detail =
|
||
if socket.assigns.valid_time do
|
||
Weather.weather_point_detail(lat, lon, socket.assigns.valid_time)
|
||
end
|
||
|
||
payload = detail || %{}
|
||
{:noreply, push_event(socket, "point_detail", payload)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:weather_updated, _valid_time}, socket) do
|
||
data = Weather.latest_weather_grid(socket.assigns.bounds)
|
||
valid_time = if data != [], do: hd(data).valid_time
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:valid_time, valid_time)
|
||
|> push_event("update_weather", %{data: data})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
@group_order ["Surface", "Upper Air", "Ducting"]
|
||
|
||
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
|
||
|
||
@impl true
|
||
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-weather={@initial_data_json}
|
||
data-layers={Jason.encode!(@layers)}
|
||
data-selected-layer={@selected_layer}
|
||
class="absolute inset-0 z-0"
|
||
>
|
||
</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">Weather Map</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>
|
||
|
||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||
<.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="/beacons" class="btn btn-xs btn-ghost justify-start">
|
||
Beacons
|
||
</.link>
|
||
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
||
Scoring Algorithm
|
||
</.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="/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">Weather Map</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 --%>
|
||
<div class="text-[11px] opacity-70 px-1 leading-snug">
|
||
{layer_description(@layers, @selected_layer)}
|
||
</div>
|
||
|
||
<%!-- Navigation --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<li>
|
||
<.link navigate="/map">Propagation Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/path">Path Calculator</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/beacons">Beacons</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/algo">Scoring Algorithm</.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="/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
|