Adds Microwaveprop.Propagation.AsosNudge: a pure IDW bias-field module that takes ASOS observations + HRRR profiles and returns re-scored grid rows for every cell within 250km of a reporting station. Upper-air fields (min_refractivity_gradient, pwat_mm, hpbl_m, profile, duct metadata) pass through unchanged so HRRR's signal isn't clobbered. The old AsosAdjustmentWorker was unwired and buggy — nil'd out ~22% of the scoring weight and wrote orphan timestamps. Replaced with a slim worker that queries the latest HRRR valid_time, fetches live ASOS currents, calls AsosNudge.compute/3, and upserts onto (lat, lon, valid_time, band_mhz) so nudged values overwrite the HRRR hour cleanly instead of polluting available_valid_times. After each upsert it warms ScoreCache and broadcasts propagation:updated so live /map clients refresh. Cron hooked up every 10 minutes in config.exs and dev.exs. Also cleaned up the stale "dev has propagation disabled" note in CLAUDE.md. 13 new AsosNudge unit tests cover: residual computation (co-located, out-of-grid, nil fields), IDW weighting (single station, far station, two equidistant stations, nil component handling), upper-air preservation, and the compute/3 entry point's shape and radius filter. Drive-by Styler formatting touched a handful of unrelated files from `mix format`.
295 lines
10 KiB
Elixir
295 lines
10 KiB
Elixir
defmodule MicrowavepropWeb.ContactMapLive do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Radio
|
|
|
|
@band_colors %{
|
|
1296 => "#475569",
|
|
2304 => "#7c3aed",
|
|
3456 => "#4f46e5",
|
|
5760 => "#2563eb",
|
|
10_000 => "#059669",
|
|
24_000 => "#d97706",
|
|
47_000 => "#ea580c",
|
|
68_000 => "#dc2626",
|
|
75_000 => "#c026d3",
|
|
122_000 => "#db2777",
|
|
134_000 => "#e11d48",
|
|
241_000 => "#b91c1c"
|
|
}
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
# Shell renders immediately with filter chrome (count + bands list). The
|
|
# JS hook fetches the ~5 MB contacts payload from `/api/contacts/map`
|
|
# after mount, which streams it as pre-gzipped bytes via the browser's
|
|
# native `Content-Encoding: gzip` handling. This gets the heavy payload
|
|
# off the initial HTML entirely.
|
|
%{count: count, bands: bands} = Radio.contact_map_payload()
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Contact Map",
|
|
contact_count: count,
|
|
all_bands: bands,
|
|
enabled_bands: MapSet.new(bands),
|
|
callsign_filter: ""
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_band", %{"band" => band_str}, socket) do
|
|
band = String.to_integer(band_str)
|
|
|
|
enabled =
|
|
if MapSet.member?(socket.assigns.enabled_bands, band) do
|
|
MapSet.delete(socket.assigns.enabled_bands, band)
|
|
else
|
|
MapSet.put(socket.assigns.enabled_bands, band)
|
|
end
|
|
|
|
socket = assign(socket, enabled_bands: enabled)
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
def handle_event("bands_all", _params, socket) do
|
|
socket = assign(socket, enabled_bands: MapSet.new(socket.assigns.all_bands))
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
def handle_event("bands_none", _params, socket) do
|
|
socket = assign(socket, enabled_bands: MapSet.new())
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
def handle_event("filter_callsign", %{"callsign" => callsign}, socket) do
|
|
socket = assign(socket, callsign_filter: String.trim(callsign))
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
defp push_filter(socket) do
|
|
push_event(socket, "apply_filter", %{
|
|
enabled_bands: MapSet.to_list(socket.assigns.enabled_bands),
|
|
callsign: socket.assigns.callsign_filter
|
|
})
|
|
end
|
|
|
|
defp band_label(mhz) when mhz >= 1000 do
|
|
ghz = mhz / 1000
|
|
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: "#{Float.round(ghz / 1, 1)}"
|
|
"#{label} GHz"
|
|
end
|
|
|
|
defp band_label(mhz), do: "#{mhz} MHz"
|
|
|
|
defp band_color(mhz), do: Map.get(@band_colors, mhz, "#64748b")
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="fixed inset-0 flex">
|
|
<div class="flex-1 relative">
|
|
<div
|
|
id="contacts-map"
|
|
phx-hook="ContactsMap"
|
|
phx-update="ignore"
|
|
data-fetch-url="/api/contacts/map"
|
|
class="absolute inset-0 z-0"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Mobile floating controls --%>
|
|
<div
|
|
id="map-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>Contact Map</span>
|
|
<div id="contact-count-mobile" class="font-normal text-xs opacity-70">
|
|
{Integer.to_string(@contact_count)} contacts
|
|
</div>
|
|
</div>
|
|
<button
|
|
id="mobile-panel-toggle"
|
|
class="btn btn-xs btn-square btn-ghost"
|
|
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
|
|
>
|
|
<.icon name="hero-bars-3" class="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div id="panel-extras" class="hidden flex-col gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Filter callsign..."
|
|
phx-keyup="filter_callsign"
|
|
phx-key="Enter"
|
|
phx-change="filter_callsign"
|
|
phx-debounce="300"
|
|
name="callsign"
|
|
value={@callsign_filter}
|
|
class="input input-xs w-full"
|
|
/>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs font-bold">Bands</span>
|
|
<span class="flex gap-2">
|
|
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
|
|
<button phx-click="bands_none" class="text-[10px] underline opacity-70">
|
|
None
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<%= for band <- @all_bands do %>
|
|
<label class="flex items-center gap-2 cursor-pointer text-xs">
|
|
<input
|
|
type="checkbox"
|
|
checked={MapSet.member?(@enabled_bands, band)}
|
|
phx-click="toggle_band"
|
|
phx-value-band={band}
|
|
style={"accent-color:#{band_color(band)}"}
|
|
/>
|
|
<span
|
|
class="inline-block w-3 h-0.5 rounded-sm shrink-0"
|
|
style={"background:#{band_color(band)}"}
|
|
>
|
|
</span>
|
|
<span>{band_label(band)}</span>
|
|
</label>
|
|
<% end %>
|
|
|
|
<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="/contacts" class="btn btn-xs btn-ghost justify-start">
|
|
Contacts
|
|
</.link>
|
|
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
|
Submit a Contact
|
|
</.link>
|
|
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
|
About
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Desktop right sidebar --%>
|
|
<div
|
|
id="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">
|
|
<span>Contact Map</span>
|
|
<div id="contact-count" class="font-normal text-xs opacity-70">
|
|
{Integer.to_string(@contact_count)} contacts
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Callsign filter --%>
|
|
<input
|
|
type="text"
|
|
placeholder="Filter callsign..."
|
|
phx-keyup="filter_callsign"
|
|
phx-key="Enter"
|
|
phx-change="filter_callsign"
|
|
phx-debounce="300"
|
|
name="callsign"
|
|
value={@callsign_filter}
|
|
class="input input-sm w-full"
|
|
/>
|
|
|
|
<%!-- Band checkboxes --%>
|
|
<div class="flex items-center justify-between px-1">
|
|
<span class="text-xs font-bold">Bands</span>
|
|
<span class="flex gap-2">
|
|
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
|
|
<button phx-click="bands_none" class="text-[10px] underline opacity-70">None</button>
|
|
</span>
|
|
</div>
|
|
|
|
<%= for band <- @all_bands do %>
|
|
<label class="flex items-center gap-2 cursor-pointer px-1 text-sm leading-relaxed">
|
|
<input
|
|
type="checkbox"
|
|
checked={MapSet.member?(@enabled_bands, band)}
|
|
phx-click="toggle_band"
|
|
phx-value-band={band}
|
|
style={"accent-color:#{band_color(band)}"}
|
|
/>
|
|
<span
|
|
class="inline-block w-3.5 h-0.5 rounded-sm shrink-0"
|
|
style={"background:#{band_color(band)}"}
|
|
>
|
|
</span>
|
|
<span>{band_label(band)}</span>
|
|
</label>
|
|
<% end %>
|
|
|
|
<%!-- 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="/contacts">Contacts</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/submit">Submit a Contact</.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 class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</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
|