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(
- `
+ // 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}
>
-