prop/lib/microwaveprop_web/live/contact_map_live.ex
Graham McIntire 77656ba023 Move contacts map controls to sidebar matching main map layout
Replace the JS-built Leaflet control panel with a server-side
sidebar (desktop) and mobile floating controls. Band checkboxes,
callsign filter, and nav links now match the main propagation map
layout. Filter state managed by LiveView, pushed to JS via events.
2026-04-11 16:21:04 -05:00

344 lines
11 KiB
Elixir

defmodule MicrowavepropWeb.ContactMapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
import Ecto.Query
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@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
contacts = load_contacts()
bands =
contacts
|> Enum.map(fn [_, _, _, _, band | _] -> band end)
|> Enum.uniq()
|> Enum.sort()
enabled_bands = MapSet.new(bands)
{:ok,
assign(socket,
page_title: "Contact Map",
contacts_json: Jason.encode!(contacts),
contact_count: length(contacts),
all_bands: bands,
enabled_bands: enabled_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")
defp load_contacts do
from(c in Contact,
where: not is_nil(c.pos1) and not is_nil(c.pos2),
select: %{
id: c.id,
pos1: c.pos1,
pos2: c.pos2,
band: c.band,
station1: c.station1,
station2: c.station2,
mode: c.mode,
distance_km: c.distance_km,
qso_timestamp: c.qso_timestamp
}
)
|> Repo.all()
|> Enum.map(fn c ->
lat1 = c.pos1["lat"]
lon1 = c.pos1["lon"] || c.pos1["lng"]
lat2 = c.pos2["lat"]
lon2 = c.pos2["lon"] || c.pos2["lng"]
band = if c.band, do: Decimal.to_integer(c.band), else: 0
dist = if c.distance_km, do: Decimal.to_float(c.distance_km)
ts = if c.qso_timestamp, do: Calendar.strftime(c.qso_timestamp, "%Y-%m-%d %H:%M")
if lat1 && lon1 && lat2 && lon2 do
[lat1, lon1, lat2, lon2, band, c.station1, c.station2, c.mode, dist, ts, c.id]
end
end)
|> Enum.reject(&is_nil/1)
|> dedup_reciprocals()
end
# Deduplicate reciprocal contacts (A↔B same band/hour = same contact logged by both sides)
defp dedup_reciprocals(contacts) do
Enum.uniq_by(contacts, fn [_lat1, _lon1, _lat2, _lon2, band, s1, s2, _mode, _dist, ts, _id] ->
stations = Enum.sort([s1 || "", s2 || ""])
hour = if ts, do: String.slice(ts, 0..12), else: ""
{stations, band, hour}
end)
end
@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"
class="absolute inset-0 z-0"
data-contacts={@contacts_json}
>
</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