diff --git a/assets/js/contacts_map_hook.js b/assets/js/contacts_map_hook.js index 2be531db..c80288b3 100644 --- a/assets/js/contacts_map_hook.js +++ b/assets/js/contacts_map_hook.js @@ -28,7 +28,11 @@ function bandColor(band) { export const ContactsMap = { mounted() { - const contacts = JSON.parse(this.el.dataset.contacts) + this.allContacts = JSON.parse(this.el.dataset.contacts) + this.callsignFilter = "" + this.enabledBands = new Set() + this.lineLayer = L.layerGroup() + this.dotLayer = L.layerGroup() this.map = L.map(this.el, { center: [37.5, -96], @@ -43,88 +47,38 @@ export const ContactsMap = { maxZoom: 19 }).addTo(this.map) - // Group contacts by band for layered rendering - const byBand = {} - for (const c of contacts) { - const band = c[4] - if (!byBand[band]) byBand[band] = [] - byBand[band].push(c) - } + this.lineLayer.addTo(this.map) + this.dotLayer.addTo(this.map) - // Collect all endpoint coords for endpoint density layer - const endpointCounts = {} + // 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) - // Draw lines per band — interactive with popups - const bandLayers = {} - for (const [band, entries] of Object.entries(byBand)) { - const color = bandColor(parseInt(band)) - const lines = [] + this.buildControlPanel() + this.rebuildMap() + }, - for (const c of entries) { - const [lat1, lon1, lat2, lon2, bnd, s1, s2, mode, dist, ts, id] = c - const line = L.polyline([[lat1, lon1], [lat2, lon2]], { - color: color, - weight: 2, - opacity: 0.5 - }) - - const distStr = dist != null ? `${Math.round(dist)} km` : "—" - line.bindPopup( - `
` + - `${s1 || "?"} ↔ ${s2 || "?"}
` + - `${bandLabel(bnd)} · ${mode || "?"}
` + - `${distStr} · ${ts || "?"} UTC
` + - `View details →` + - `
`, - {closeButton: false, offset: [0, -4]} - ) - - line.on("mouseover", function() { this.setStyle({weight: 4, opacity: 1}) }) - line.on("mouseout", function() { this.setStyle({weight: 2, opacity: 0.5}) }) - - lines.push(line) - - // Track endpoints - const k1 = `${lat1.toFixed(2)},${lon1.toFixed(2)}` - const k2 = `${lat2.toFixed(2)},${lon2.toFixed(2)}` - endpointCounts[k1] = (endpointCounts[k1] || 0) + 1 - endpointCounts[k2] = (endpointCounts[k2] || 0) + 1 - } - - const layer = L.layerGroup(lines) - bandLayers[band] = layer - layer.addTo(this.map) - } - - // Draw endpoint dots sized by density - const dotLayer = L.layerGroup() - 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) - - L.circleMarker([lat, lon], { - radius: radius, - color: "#fff", - weight: 1, - fillColor: "#3b82f6", - fillOpacity: 0.7, - interactive: false - }).addTo(dotLayer) - } - dotLayer.addTo(this.map) - - // Build interactive band filter - const bandControl = L.control({position: "bottomright"}) + buildControlPanel() { + const control = L.control({position: "bottomright"}) const self = this - bandControl.onAdd = function(map) { + + 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;" + 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) - const sorted = Object.entries(byBand).sort((a, b) => parseInt(a[0]) - parseInt(b[0])) + // Callsign filter + let html = `
+ +
` - let html = `
+ // Band header with All/None + html += `
Bands @@ -132,46 +86,142 @@ export const ContactsMap = {
` - for (const [band, entries] of sorted) { - const color = bandColor(parseInt(band)) - const label = bandLabel(parseInt(band)) + // Band checkboxes + for (const band of self.allBands) { + const color = bandColor(band) + const label = bandLabel(band) html += `` } div.innerHTML = html - // Wire up checkboxes + // 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 = cb.dataset.band + const band = parseInt(cb.dataset.band) if (cb.checked) { - bandLayers[band].addTo(map) + self.enabledBands.add(band) } else { - bandLayers[band].remove() + self.enabledBands.delete(band) } + self.rebuildMap() }) }) div.querySelector("#bands-all").addEventListener("click", () => { - div.querySelectorAll("input[data-band]").forEach(cb => { - cb.checked = true - bandLayers[cb.dataset.band].addTo(map) - }) + 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 - bandLayers[cb.dataset.band].remove() - }) + div.querySelectorAll("input[data-band]").forEach(cb => { cb.checked = false }) + self.enabledBands.clear() + self.rebuildMap() }) + self.controlDiv = div return div } - bandControl.addTo(this.map) + + control.addTo(this.map) + }, + + rebuildMap() { + this.lineLayer.clearLayers() + this.dotLayer.clearLayers() + + 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 + }) + + // 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 + const countEl = document.getElementById("contact-count") + if (countEl) { + countEl.textContent = `${filtered.length.toLocaleString()} contacts` + } + + // Draw lines + const endpointCounts = {} + for (const c of filtered) { + const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c + const color = bandColor(band) + + const line = L.polyline([[lat1, lon1], [lat2, lon2]], { + color, weight: 2, opacity: 0.5 + }) + + const distStr = dist != null ? `${Math.round(dist)} km` : "—" + line.bindPopup( + `
` + + `${s1 || "?"} ↔ ${s2 || "?"}
` + + `${bandLabel(band)} · ${mode || "?"}
` + + `${distStr} · ${ts || "?"} UTC
` + + `View details →` + + `
`, + {closeButton: false, offset: [0, -4]} + ) + + line.on("mouseover", function() { this.setStyle({weight: 4, opacity: 1}) }) + line.on("mouseout", function() { this.setStyle({weight: 2, opacity: 0.5}) }) + + this.lineLayer.addLayer(line) + + const k1 = `${lat1.toFixed(2)},${lon1.toFixed(2)}` + const k2 = `${lat2.toFixed(2)},${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) + + L.circleMarker([lat, lon], { + radius, color: "#fff", weight: 1, + fillColor: "#3b82f6", fillOpacity: 0.7, + interactive: false + }).addTo(this.dotLayer) + } } } diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex index f8e73e0b..531e3c2b 100644 --- a/lib/microwaveprop_web/live/contact_map_live.ex +++ b/lib/microwaveprop_web/live/contact_map_live.ex @@ -72,9 +72,9 @@ defmodule MicrowavepropWeb.ContactMapLive do data-contacts={@contacts_json} >
-
+
Contact Map
-
{Integer.to_string(@contact_count)} contacts
+
{Integer.to_string(@contact_count)} contacts
""" end