prop/lib/microwaveprop_web/live/contact_map_live.ex
Graham McIntire 18680b35e5
fix(contacts/map): align line colors with sidebar legend
The polylines were sourcing from --band-* CSS vars (a separate, theme-aware
design palette) while the legend swatches rendered from the Elixir @band_colors
map, so the two diverged for every band — e.g. 10 GHz showed lime on the map
and emerald in the legend. Emit `data-band-color` on each band checkbox and
have the JS hook read from there, making the legend the single source of truth.
Drops the now-redundant theme observer / restyleLines plumbing.
2026-05-05 13:56:02 -05:00

394 lines
13 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"
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" 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"
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" 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