Add callsign filter and band counts to contacts map
- Callsign input filters contacts where either station matches (case-insensitive substring match, debounced 300ms) - Band checkboxes now show per-band count of visible contacts - All/None buttons for quick band selection - Header count updates dynamically with filters - Complete rewrite: rebuilds map on any filter change instead of toggling individual layers (simpler, handles cross-filter correctly)
This commit is contained in:
parent
6d30714521
commit
2aa76727f9
2 changed files with 143 additions and 93 deletions
|
|
@ -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(
|
||||
`<div style="font-size:12px;line-height:1.5;">` +
|
||||
`<strong>${s1 || "?"} ↔ ${s2 || "?"}</strong><br/>` +
|
||||
`${bandLabel(bnd)} · ${mode || "?"}<br/>` +
|
||||
`${distStr} · ${ts || "?"} UTC<br/>` +
|
||||
`<a href="/contacts/${id}" style="color:#3b82f6;">View details →</a>` +
|
||||
`</div>`,
|
||||
{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 = `<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>`
|
||||
|
||||
let html = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
|
||||
// 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>
|
||||
|
|
@ -132,46 +86,142 @@ export const ContactsMap = {
|
|||
</span>
|
||||
</div>`
|
||||
|
||||
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 += `<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} (${entries.length.toLocaleString()})</span>
|
||||
<span>${label} <span class="band-count" data-band="${band}"></span></span>
|
||||
</label>`
|
||||
}
|
||||
|
||||
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(
|
||||
`<div style="font-size:12px;line-height:1.5;">` +
|
||||
`<strong>${s1 || "?"} ↔ ${s2 || "?"}</strong><br/>` +
|
||||
`${bandLabel(band)} · ${mode || "?"}<br/>` +
|
||||
`${distStr} · ${ts || "?"} UTC<br/>` +
|
||||
`<a href="/contacts/${id}" style="color:#3b82f6;">View details →</a>` +
|
||||
`</div>`,
|
||||
{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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,9 +72,9 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
data-contacts={@contacts_json}
|
||||
>
|
||||
</div>
|
||||
<div class="fixed top-3 left-14 z-[1000] bg-base-100/90 shadow rounded-box border border-base-300 px-3 py-2">
|
||||
<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 class="text-xs opacity-70">{Integer.to_string(@contact_count)} contacts</div>
|
||||
<div id="contact-count" class="text-xs opacity-70">{Integer.to_string(@contact_count)} contacts</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue