From 6972feb2c224e74502c8898eaee07b05896c200e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 17 Apr 2026 12:08:46 -0500 Subject: [PATCH] perf(contact-map): client-side band filtering via per-band layer groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Band toggles previously roundtripped through LiveView and rebuilt every polyline + popup + hover handler on each click. Now the hook builds one L.LayerGroup per band once after the initial fetch; toggling is just addLayer/removeLayer on the map (O(bands), not O(contacts)). Checkbox and All/None events are handled directly in the hook via delegated document listeners — no LV roundtrip. Callsign filter still roundtrips (debounced) since it's cross-cutting, and now iterates pre-built polylines without recreating them. --- assets/js/contacts_map_hook.ts | 236 +++++++++++++----- .../live/contact_map_live.ex | 137 +++++----- 2 files changed, 237 insertions(+), 136 deletions(-) diff --git a/assets/js/contacts_map_hook.ts b/assets/js/contacts_map_hook.ts index c7c205e2..9a47ddfa 100644 --- a/assets/js/contacts_map_hook.ts +++ b/assets/js/contacts_map_hook.ts @@ -6,35 +6,53 @@ type ContactTuple = [ ] interface ApplyFilterPayload { - enabled_bands: number[] callsign: string | null } +interface LineEntry { + line: L.Polyline + band: number + s1: string + s2: string + lat1: number + lon1: number + lat2: number + lon2: number +} + interface ContactsMapHook extends ViewHook { allContacts: ContactTuple[] callsignFilter: string enabledBands: Set - lineLayer: L.LayerGroup + bandGroups: Map + lines: LineEntry[] dotLayer: L.LayerGroup map: L.Map + delegatedChange: (e: Event) => void + delegatedClick: (e: Event) => void initMap(this: ContactsMapHook): void - rebuildMap(this: ContactsMapHook): void + buildLines(this: ContactsMapHook): void + syncBandLayers(this: ContactsMapHook): void + applyCallsignFilter(this: ContactsMapHook): void + rebuildDots(this: ContactsMapHook): void + updateCount(this: ContactsMapHook, count: number): void + wireControls(this: ContactsMapHook): void + unwireControls(this: ContactsMapHook): void } -// Band MHz -> color mapping const BAND_COLORS: Record = { - 1296: "#475569", // slate-600 - 2304: "#7c3aed", // violet-600 - 3400: "#4f46e5", // indigo-600 - 5760: "#2563eb", // blue-600 - 10000: "#059669", // emerald-600 - 24000: "#d97706", // amber-600 - 47000: "#ea580c", // orange-600 - 68000: "#dc2626", // red-600 - 75000: "#c026d3", // fuchsia-600 - 122000: "#db2777", // pink-600 - 134000: "#e11d48", // rose-600 - 241000: "#b91c1c", // red-700 + 1296: "#475569", + 2304: "#7c3aed", + 3400: "#4f46e5", + 5760: "#2563eb", + 10000: "#059669", + 24000: "#d97706", + 47000: "#ea580c", + 68000: "#dc2626", + 75000: "#c026d3", + 122000: "#db2777", + 134000: "#e11d48", + 241000: "#b91c1c", } function bandLabel(mhz: number): string { @@ -51,44 +69,90 @@ function bandColor(band: number): string { export const ContactsMap = { mounted(this: ContactsMapHook) { - // Contacts payload (~5 MB) comes from a standalone HTTP endpoint that - // serves pre-gzipped JSON. This keeps it out of the initial HTML and - // lets the browser cache it independently of the LiveView. const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map" this.allContacts = [] this.callsignFilter = "" this.enabledBands = new Set() - this.lineLayer = L.layerGroup() + this.bandGroups = new Map() + this.lines = [] this.dotLayer = L.layerGroup() - // Listen for filter changes from LiveView - this.handleEvent("apply_filter", ({enabled_bands, callsign}: ApplyFilterPayload) => { - this.enabledBands = new Set(enabled_bands) + // Callsign filter still roundtrips through LiveView (debounced) — it's + // cross-cutting and fires rarely. Band toggles are purely client-side. + this.handleEvent("apply_filter", ({callsign}: ApplyFilterPayload) => { this.callsignFilter = (callsign || "").toUpperCase() - this.rebuildMap() + this.applyCallsignFilter() + this.rebuildDots() }) - // Defer map init until container has dimensions + this.wireControls() + requestAnimationFrame(() => this.initMap()) - // Fetch contacts in parallel with map init. Once both are done, - // `rebuildMap()` draws everything. Starts earlier than a LiveView - // push_event because the HTTP request doesn't wait for the socket - // handshake. - // No explicit Accept header — the :browser pipeline's `plug :accepts` - // only whitelists "html", so setting accept: application/json triggers - // a 406. The default `*/*` matches fine and we still parse the body - // as JSON ourselves. fetch(fetchUrl) .then(resp => resp.json()) .then(contacts => { this.allContacts = contacts for (const c of contacts) this.enabledBands.add(c[4]) - if (this.map) this.rebuildMap() + if (this.map) { + this.buildLines() + this.syncBandLayers() + this.rebuildDots() + } }) .catch(err => console.error("Failed to load contacts:", err)) }, + destroyed(this: ContactsMapHook) { + this.unwireControls() + }, + + wireControls(this: ContactsMapHook) { + // Band checkboxes and All/None buttons live outside this hook's element + // (they're in the sidebar), so delegate from the document. The checkboxes + // have data-band and the bulk buttons have data-band-action="all"|"none". + this.delegatedChange = (e: Event) => { + const t = e.target as HTMLInputElement + if (!t || !t.matches || !t.matches("input[data-band]")) return + const band = parseInt(t.dataset.band || "", 10) + if (!Number.isFinite(band)) return + if (t.checked) this.enabledBands.add(band) + else this.enabledBands.delete(band) + this.syncBandLayers() + this.rebuildDots() + } + + this.delegatedClick = (e: Event) => { + const t = e.target as HTMLElement + if (!t || !t.matches || !t.matches("[data-band-action]")) return + const action = t.dataset.bandAction + const inputs = document.querySelectorAll("input[data-band]") + if (action === "all") { + this.enabledBands = new Set() + for (const inp of inputs) { + inp.checked = true + const band = parseInt(inp.dataset.band || "", 10) + if (Number.isFinite(band)) this.enabledBands.add(band) + } + } else if (action === "none") { + this.enabledBands = new Set() + for (const inp of inputs) inp.checked = false + } else { + return + } + this.syncBandLayers() + this.rebuildDots() + } + + document.addEventListener("change", this.delegatedChange) + document.addEventListener("click", this.delegatedClick) + }, + + unwireControls(this: ContactsMapHook) { + if (this.delegatedChange) document.removeEventListener("change", this.delegatedChange) + if (this.delegatedClick) document.removeEventListener("click", this.delegatedClick) + }, + initMap(this: ContactsMapHook) { this.map = L.map(this.el, { center: [37.5, -96], @@ -103,41 +167,24 @@ export const ContactsMap = { maxZoom: 19 }).addTo(this.map) - this.lineLayer.addTo(this.map) this.dotLayer.addTo(this.map) - // If contacts arrived before the map was ready, draw them now. if (this.allContacts.length > 0) { for (const c of this.allContacts) this.enabledBands.add(c[4]) - this.rebuildMap() + this.buildLines() + this.syncBandLayers() + this.rebuildDots() } }, - rebuildMap(this: ContactsMapHook) { - this.lineLayer.clearLayers() - this.dotLayer.clearLayers() + buildLines(this: ContactsMapHook) { + // One-time build of every polyline, grouped into per-band LayerGroups. + // After this, band toggling is just addLayer/removeLayer on the map — + // no polyline recreation ever. + this.lines = [] + this.bandGroups = new Map() - const filtered = this.allContacts.filter(c => { - const [, , , , band, s1, s2] = c - if (!this.enabledBands.has(band)) return false - if (this.callsignFilter) { - const match = this.callsignFilter - const st1 = (s1 || "").toUpperCase() - const st2 = (s2 || "").toUpperCase() - if (!st1.includes(match) && !st2.includes(match)) return false - } - return true - }) - - // Update count displays - const countEl = document.getElementById("contact-count") - 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: Record = {} - for (const c of filtered) { + for (const c of this.allContacts) { const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c const color = bandColor(band) @@ -159,15 +206,64 @@ export const ContactsMap = { line.on("mouseover", function(this: L.Polyline) { this.setStyle({weight: 4, opacity: 1}) }) line.on("mouseout", function(this: L.Polyline) { this.setStyle({weight: 2, opacity: 0.5}) }) - this.lineLayer.addLayer(line) + let group = this.bandGroups.get(band) + if (!group) { + group = L.layerGroup() + this.bandGroups.set(band, group) + } + group.addLayer(line) - const k1 = `${lat1.toFixed(2)},${lon1.toFixed(2)}` - const k2 = `${lat2.toFixed(2)},${lon2.toFixed(2)}` + this.lines.push({ + line, + band, + s1: (s1 || "").toUpperCase(), + s2: (s2 || "").toUpperCase(), + lat1, lon1, lat2, lon2 + }) + } + }, + + syncBandLayers(this: ContactsMapHook) { + // Fast path: flip whole-group on/off. O(bands), not O(contacts). + for (const [band, group] of this.bandGroups) { + const enabled = this.enabledBands.has(band) + const present = this.map.hasLayer(group) + if (enabled && !present) this.map.addLayer(group) + else if (!enabled && present) this.map.removeLayer(group) + } + }, + + applyCallsignFilter(this: ContactsMapHook) { + // Callsign filter: iterate individual polylines and add/remove them + // from their band's group. Cheap compared to rebuild (no polyline + // creation). Only runs when the filter string changes. + const match = this.callsignFilter + for (const entry of this.lines) { + const group = this.bandGroups.get(entry.band) + if (!group) continue + const visible = !match || entry.s1.includes(match) || entry.s2.includes(match) + const present = group.hasLayer(entry.line) + if (visible && !present) group.addLayer(entry.line) + else if (!visible && present) group.removeLayer(entry.line) + } + }, + + rebuildDots(this: ContactsMapHook) { + this.dotLayer.clearLayers() + const match = this.callsignFilter + const endpointCounts: Record = {} + let lineCount = 0 + + for (const entry of this.lines) { + if (!this.enabledBands.has(entry.band)) continue + if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue + lineCount++ + const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}` + const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}` endpointCounts[k1] = (endpointCounts[k1] || 0) + 1 endpointCounts[k2] = (endpointCounts[k2] || 0) + 1 } - // Draw endpoint dots for (const [key, count] of Object.entries(endpointCounts)) { const [lat, lon] = key.split(",").map(Number) const radius = Math.min(2 + Math.log2(count) * 1.5, 10) @@ -178,5 +274,15 @@ export const ContactsMap = { interactive: false }).addTo(this.dotLayer) } + + this.updateCount(lineCount) + }, + + updateCount(this: ContactsMapHook, count: number) { + const label = `${count.toLocaleString()} contacts` + const countEl = document.getElementById("contact-count") + if (countEl) countEl.textContent = label + const countMobile = document.getElementById("contact-count-mobile") + if (countMobile) countMobile.textContent = label } } diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex index 4f9e7aa8..76ae8692 100644 --- a/lib/microwaveprop_web/live/contact_map_live.ex +++ b/lib/microwaveprop_web/live/contact_map_live.ex @@ -26,6 +26,11 @@ defmodule MicrowavepropWeb.ContactMapLive do # 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, @@ -33,46 +38,20 @@ defmodule MicrowavepropWeb.ContactMapLive do 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 + trimmed = String.trim(callsign) - defp push_filter(socket) do - push_event(socket, "apply_filter", %{ - enabled_bands: MapSet.to_list(socket.assigns.enabled_bands), - callsign: socket.assigns.callsign_filter - }) + socket = + socket + |> assign(callsign_filter: trimmed) + |> push_event("apply_filter", %{callsign: trimmed}) + + {:noreply, socket} end defp band_label(mhz) when mhz >= 1000 do @@ -140,30 +119,41 @@ defmodule MicrowavepropWeb.ContactMapLive do
Bands - - +
- <%= for band <- @all_bands do %> - - <% end %> +
+ <%= for band <- @all_bands do %> + + <% end %> +
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start"> @@ -221,28 +211,33 @@ defmodule MicrowavepropWeb.ContactMapLive do
Bands - - + +
- <%= for band <- @all_bands do %> - - <% end %> +
+ <%= for band <- @all_bands do %> + + <% end %> +
<%!-- Navigation --%>