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.
This commit is contained in:
parent
b468232b0e
commit
77656ba023
2 changed files with 287 additions and 121 deletions
|
|
@ -50,98 +50,19 @@ export const ContactsMap = {
|
|||
this.lineLayer.addTo(this.map)
|
||||
this.dotLayer.addTo(this.map)
|
||||
|
||||
// Discover all bands
|
||||
const bandSet = new Set()
|
||||
for (const c of this.allContacts) bandSet.add(c[4])
|
||||
this.allBands = [...bandSet].sort((a, b) => a - b)
|
||||
for (const b of this.allBands) this.enabledBands.add(b)
|
||||
// Enable all bands initially
|
||||
for (const c of this.allContacts) this.enabledBands.add(c[4])
|
||||
|
||||
// Listen for filter changes from LiveView
|
||||
this.handleEvent("apply_filter", ({enabled_bands, callsign}) => {
|
||||
this.enabledBands = new Set(enabled_bands)
|
||||
this.callsignFilter = (callsign || "").toUpperCase()
|
||||
this.rebuildMap()
|
||||
})
|
||||
|
||||
this.buildControlPanel()
|
||||
this.rebuildMap()
|
||||
},
|
||||
|
||||
buildControlPanel() {
|
||||
const control = L.control({position: "bottomright"})
|
||||
const self = this
|
||||
|
||||
control.onAdd = function() {
|
||||
const div = L.DomUtil.create("div")
|
||||
div.style.cssText = "background:rgba(30,30,40,0.95);color:#fff;padding:8px 12px;border-radius:8px;font-size:11px;line-height:1.8;max-height:60vh;overflow-y:auto;"
|
||||
L.DomEvent.disableClickPropagation(div)
|
||||
L.DomEvent.disableScrollPropagation(div)
|
||||
|
||||
// Callsign filter
|
||||
let html = `<div style="margin-bottom:6px;">
|
||||
<input id="callsign-filter" type="text" placeholder="Filter by callsign"
|
||||
style="width:100%;padding:3px 6px;border-radius:4px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:#fff;font-size:11px;outline:none;"
|
||||
/>
|
||||
</div>`
|
||||
|
||||
// Band header with All/None
|
||||
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
|
||||
<span style="font-weight:700;">Bands</span>
|
||||
<span style="display:flex;gap:6px;">
|
||||
<button id="bands-all" style="background:none;border:none;color:#7dd;cursor:pointer;font-size:10px;text-decoration:underline;">All</button>
|
||||
<button id="bands-none" style="background:none;border:none;color:#d77;cursor:pointer;font-size:10px;text-decoration:underline;">None</button>
|
||||
</span>
|
||||
</div>`
|
||||
|
||||
// Band checkboxes
|
||||
for (const band of self.allBands) {
|
||||
const color = bandColor(band)
|
||||
const label = bandLabel(band)
|
||||
html += `<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" data-band="${band}" checked style="accent-color:${color};cursor:pointer;">
|
||||
<span style="display:inline-block;width:14px;height:3px;background:${color};border-radius:1px;flex-shrink:0;"></span>
|
||||
<span>${label} <span class="band-count" data-band="${band}"></span></span>
|
||||
</label>`
|
||||
}
|
||||
|
||||
div.innerHTML = html
|
||||
|
||||
// Wire callsign filter
|
||||
const input = div.querySelector("#callsign-filter")
|
||||
let debounceTimer
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
self.callsignFilter = input.value.trim().toUpperCase()
|
||||
self.rebuildMap()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// Wire band checkboxes
|
||||
div.querySelectorAll("input[data-band]").forEach(cb => {
|
||||
cb.addEventListener("change", () => {
|
||||
const band = parseInt(cb.dataset.band)
|
||||
if (cb.checked) {
|
||||
self.enabledBands.add(band)
|
||||
} else {
|
||||
self.enabledBands.delete(band)
|
||||
}
|
||||
self.rebuildMap()
|
||||
})
|
||||
})
|
||||
|
||||
div.querySelector("#bands-all").addEventListener("click", () => {
|
||||
div.querySelectorAll("input[data-band]").forEach(cb => { cb.checked = true })
|
||||
for (const b of self.allBands) self.enabledBands.add(b)
|
||||
self.rebuildMap()
|
||||
})
|
||||
|
||||
div.querySelector("#bands-none").addEventListener("click", () => {
|
||||
div.querySelectorAll("input[data-band]").forEach(cb => { cb.checked = false })
|
||||
self.enabledBands.clear()
|
||||
self.rebuildMap()
|
||||
})
|
||||
|
||||
self.controlDiv = div
|
||||
return div
|
||||
}
|
||||
|
||||
control.addTo(this.map)
|
||||
},
|
||||
|
||||
rebuildMap() {
|
||||
this.lineLayer.clearLayers()
|
||||
this.dotLayer.clearLayers()
|
||||
|
|
@ -158,27 +79,11 @@ export const ContactsMap = {
|
|||
return true
|
||||
})
|
||||
|
||||
// Count per band for display
|
||||
const bandCounts = {}
|
||||
for (const c of filtered) {
|
||||
const band = c[4]
|
||||
bandCounts[band] = (bandCounts[band] || 0) + 1
|
||||
}
|
||||
|
||||
// Update band counts in control panel
|
||||
if (this.controlDiv) {
|
||||
this.controlDiv.querySelectorAll(".band-count").forEach(el => {
|
||||
const band = parseInt(el.dataset.band)
|
||||
const count = bandCounts[band] || 0
|
||||
el.textContent = `(${count.toLocaleString()})`
|
||||
})
|
||||
}
|
||||
|
||||
// Update header count
|
||||
// Update count displays
|
||||
const countEl = document.getElementById("contact-count")
|
||||
if (countEl) {
|
||||
countEl.textContent = `${filtered.length.toLocaleString()} contacts`
|
||||
}
|
||||
if (countEl) countEl.textContent = `${filtered.length.toLocaleString()} contacts`
|
||||
const countMobile = document.getElementById("contact-count-mobile")
|
||||
if (countMobile) countMobile.textContent = `${filtered.length.toLocaleString()} contacts`
|
||||
|
||||
// Draw lines
|
||||
const endpointCounts = {}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,91 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
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)
|
||||
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),
|
||||
|
|
@ -64,20 +137,208 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="contacts-map"
|
||||
phx-hook="ContactsMap"
|
||||
phx-update="ignore"
|
||||
class="fixed inset-0 z-0"
|
||||
data-contacts={@contacts_json}
|
||||
>
|
||||
</div>
|
||||
<div class="fixed top-3 left-14 z-[1000] bg-base-100 shadow rounded-box border border-base-300 px-3 py-2 flex flex-col gap-1">
|
||||
<div class="font-bold text-sm">Contact Map</div>
|
||||
<div id="contact-count" class="text-xs opacity-70">
|
||||
{Integer.to_string(@contact_count)} contacts
|
||||
<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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue