// Band MHz -> color mapping
const BAND_COLORS = {
1296: "#475569", // slate-600
2304: "#7c3aed", // violet-600
3456: "#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
}
function bandLabel(mhz) {
if (mhz >= 1000) {
const ghz = mhz / 1000
return (Number.isInteger(ghz) ? ghz : ghz.toFixed(1)) + " GHz"
}
return mhz + " MHz"
}
function bandColor(band) {
return BAND_COLORS[band] || "#64748b"
}
export const ContactsMap = {
mounted() {
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],
zoom: 5,
minZoom: 3,
maxZoom: 14,
preferCanvas: true
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
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)
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 = `
`
// Band header with All/None
html += `
Bands
`
// Band checkboxes
for (const band of self.allBands) {
const color = bandColor(band)
const label = bandLabel(band)
html += ``
}
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()
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)
}
}
}