prop/assets/js/contacts_map_hook.js
Graham McIntire 77656ba023 Move contacts map controls to sidebar matching main map layout
Replace the JS-built Leaflet control panel with a server-side
sidebar (desktop) and mobile floating controls. Band checkboxes,
callsign filter, and nav links now match the main propagation map
layout. Filter state managed by LiveView, pushed to JS via events.
2026-04-11 16:21:04 -05:00

132 lines
4.1 KiB
JavaScript

// 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)
// Enable all bands initially
for (const c of this.allContacts) this.enabledBands.add(c[4])
// Listen for filter changes from LiveView
this.handleEvent("apply_filter", ({enabled_bands, callsign}) => {
this.enabledBands = new Set(enabled_bands)
this.callsignFilter = (callsign || "").toUpperCase()
this.rebuildMap()
})
this.rebuildMap()
},
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
})
// 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 = {}
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 || "?"} &harr; ${s2 || "?"}</strong><br/>` +
`${bandLabel(band)} &middot; ${mode || "?"}<br/>` +
`${distStr} &middot; ${ts || "?"} UTC<br/>` +
`<a href="/contacts/${id}" style="color:#3b82f6;">View details &rarr;</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)
}
}
}