- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg) - Move 12+ nested alias/import/require to module top level - Add phx-change/id attributes to 11 raw HTML <form> tags - Remove 4 unused LiveView assigns (:bounds, :data_provider) - Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts) - Break 2 long lines (path_compute.ex:382) - Strengthen weak test assertions (is_binary→byte_size, is_list→!=[]) - Replace Module.concat with Module.safe_concat (2 occurrences) - Replace length/1 > 0 with list != [] (9 occurrences) - Remove no-op assert true, fix no-assertion tests Remaining: 24 socket.assigns introspection warnings (deliberate test pattern for observable behavior testing), 1 formatter-resistant long line, 3 app-code usage warnings.
404 lines
14 KiB
Elixir
404 lines
14 KiB
Elixir
defmodule MicrowavepropWeb.ContactMapLive do
|
|
@moduledoc "`/contacts/map` plots QSO endpoints + great-circle paths on a Leaflet map."
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Radio
|
|
|
|
@band_colors %{
|
|
1296 => "#475569",
|
|
2304 => "#7c3aed",
|
|
3400 => "#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.
|
|
#
|
|
# Band toggles are handled entirely client-side in the ContactsMap hook
|
|
# (per-band Leaflet LayerGroups) so there's no enabled_bands state on
|
|
# the server. Callsign filter still roundtrips (debounced) to push the
|
|
# filter string to the hook.
|
|
%{count: count, bands: bands} = Radio.contact_map_payload()
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Contact Map",
|
|
contact_count: count,
|
|
all_bands: bands,
|
|
callsign_filter: "",
|
|
start_date: "",
|
|
end_date: ""
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("filter_callsign", %{"callsign" => callsign}, socket) do
|
|
trimmed = String.trim(callsign)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(callsign_filter: trimmed)
|
|
|> push_event("apply_filter", %{callsign: trimmed})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("filter_dates", %{"start_date" => start_date, "end_date" => end_date}, socket) do
|
|
start_trimmed = String.trim(start_date)
|
|
end_trimmed = String.trim(end_date)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(start_date: start_trimmed, end_date: end_trimmed)
|
|
|> push_event("apply_date_filter", %{start: start_trimmed, end: end_trimmed})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("clear_dates", _params, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(start_date: "", end_date: "")
|
|
|> push_event("apply_date_filter", %{start: "", end: ""})
|
|
|
|
{:noreply, socket}
|
|
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">
|
|
<form
|
|
phx-submit="filter_callsign"
|
|
phx-change="filter_callsign"
|
|
phx-debounce="500"
|
|
id="contact-map-secondary-form"
|
|
class="flex gap-1"
|
|
>
|
|
<input
|
|
type="text"
|
|
placeholder="Filter callsign..."
|
|
name="callsign"
|
|
value={@callsign_filter}
|
|
class="input input-xs flex-1 min-w-0"
|
|
/>
|
|
<button type="submit" class="btn btn-xs btn-primary">Filter</button>
|
|
</form>
|
|
|
|
<form
|
|
phx-submit="filter_dates"
|
|
phx-change="search"
|
|
id="contact-map-main-form"
|
|
class="flex flex-col gap-1"
|
|
>
|
|
<span class="text-[10px] opacity-70">Date range (UTC)</span>
|
|
<div class="flex gap-1 items-center">
|
|
<input
|
|
type="date"
|
|
name="start_date"
|
|
value={@start_date}
|
|
class="input input-xs flex-1 min-w-0"
|
|
/>
|
|
<span class="text-xs">→</span>
|
|
<input
|
|
type="date"
|
|
name="end_date"
|
|
value={@end_date}
|
|
class="input input-xs flex-1 min-w-0"
|
|
/>
|
|
</div>
|
|
<div class="flex gap-1">
|
|
<button type="submit" class="btn btn-xs btn-primary flex-1">
|
|
Apply dates
|
|
</button>
|
|
<button
|
|
:if={@start_date != "" or @end_date != ""}
|
|
type="button"
|
|
phx-click="clear_dates"
|
|
class="btn btn-xs btn-ghost"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs font-bold">Bands</span>
|
|
<span class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
data-band-action="all"
|
|
class="text-[10px] underline opacity-70"
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-band-action="none"
|
|
class="text-[10px] underline opacity-70"
|
|
>
|
|
None
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div id="band-checkboxes-mobile" phx-update="ignore">
|
|
<%= for band <- @all_bands do %>
|
|
<label class="flex items-center gap-2 cursor-pointer text-xs">
|
|
<input
|
|
type="checkbox"
|
|
checked
|
|
data-band={band}
|
|
data-band-color={band_color(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>
|
|
|
|
<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 --%>
|
|
<form
|
|
phx-submit="filter_callsign"
|
|
phx-change="filter_callsign"
|
|
phx-debounce="500"
|
|
id="contact-map-filter-form"
|
|
class="flex gap-1"
|
|
>
|
|
<input
|
|
type="text"
|
|
placeholder="Filter callsign..."
|
|
name="callsign"
|
|
value={@callsign_filter}
|
|
class="input input-sm flex-1 min-w-0"
|
|
/>
|
|
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
|
</form>
|
|
|
|
<%!-- Date range filter --%>
|
|
<form
|
|
phx-submit="filter_dates"
|
|
phx-change="search"
|
|
id="contact-map-search-form"
|
|
class="flex flex-col gap-1"
|
|
>
|
|
<span class="text-[10px] uppercase font-semibold opacity-70 px-1">
|
|
Date range (UTC)
|
|
</span>
|
|
<div class="flex gap-1 items-center">
|
|
<input
|
|
type="date"
|
|
name="start_date"
|
|
value={@start_date}
|
|
class="input input-sm flex-1 min-w-0"
|
|
/>
|
|
<span class="text-xs">→</span>
|
|
<input
|
|
type="date"
|
|
name="end_date"
|
|
value={@end_date}
|
|
class="input input-sm flex-1 min-w-0"
|
|
/>
|
|
</div>
|
|
<div class="flex gap-1">
|
|
<button type="submit" class="btn btn-sm btn-primary flex-1">
|
|
Apply dates
|
|
</button>
|
|
<button
|
|
:if={@start_date != "" or @end_date != ""}
|
|
type="button"
|
|
phx-click="clear_dates"
|
|
class="btn btn-sm btn-ghost"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<%!-- 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 type="button" data-band-action="all" class="text-[10px] underline opacity-70">
|
|
All
|
|
</button>
|
|
<button type="button" data-band-action="none" class="text-[10px] underline opacity-70">
|
|
None
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div id="band-checkboxes" class="flex flex-col" phx-update="ignore">
|
|
<%= for band <- @all_bands do %>
|
|
<label class="flex items-center gap-2 cursor-pointer px-1 text-sm leading-relaxed">
|
|
<input
|
|
type="checkbox"
|
|
checked
|
|
data-band={band}
|
|
data-band-color={band_color(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 %>
|
|
</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="/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>
|
|
<.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
|