prop/assets/js/contacts_map_hook.ts
Graham McIntire f93f5c9a57
Rename 9cm band 3456 → 3400
The US 9cm amateur allocation was cut from 3300-3500 to 3300-3450 MHz
in 2020, so 3456 (the legacy weak-signal calling frequency) is no
longer in-band. Move the canonical key to 3400, migrate existing
contacts, and keep 3456 input resolving to 3400 via the
nearest-band snap so historical ADIF/CSV logs still import cleanly.
2026-04-16 12:40:38 -05:00

182 lines
6.1 KiB
TypeScript

// Contact tuple indices: [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id]
type ContactTuple = [
number, number, number, number, number,
string | null, string | null, string | null,
number | null, string | null, string
]
interface ApplyFilterPayload {
enabled_bands: number[]
callsign: string | null
}
interface ContactsMapHook extends ViewHook {
allContacts: ContactTuple[]
callsignFilter: string
enabledBands: Set<number>
lineLayer: L.LayerGroup
dotLayer: L.LayerGroup
map: L.Map
initMap(this: ContactsMapHook): void
rebuildMap(this: ContactsMapHook): void
}
// Band MHz -> color mapping
const BAND_COLORS: Record<number, string> = {
1296: "#475569", // slate-600
2304: "#7c3aed", // violet-600
3400: "#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: number): string {
if (mhz >= 1000) {
const ghz = mhz / 1000
return (Number.isInteger(ghz) ? ghz : ghz.toFixed(1)) + " GHz"
}
return mhz + " MHz"
}
function bandColor(band: number): string {
return BAND_COLORS[band] || "#64748b"
}
export const ContactsMap = {
mounted(this: ContactsMapHook) {
// Contacts payload (~5 MB) comes from a standalone HTTP endpoint that
// serves pre-gzipped JSON. This keeps it out of the initial HTML and
// lets the browser cache it independently of the LiveView.
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
this.allContacts = []
this.callsignFilter = ""
this.enabledBands = new Set()
this.lineLayer = L.layerGroup()
this.dotLayer = L.layerGroup()
// Listen for filter changes from LiveView
this.handleEvent("apply_filter", ({enabled_bands, callsign}: ApplyFilterPayload) => {
this.enabledBands = new Set(enabled_bands)
this.callsignFilter = (callsign || "").toUpperCase()
this.rebuildMap()
})
// Defer map init until container has dimensions
requestAnimationFrame(() => this.initMap())
// Fetch contacts in parallel with map init. Once both are done,
// `rebuildMap()` draws everything. Starts earlier than a LiveView
// push_event because the HTTP request doesn't wait for the socket
// handshake.
// No explicit Accept header — the :browser pipeline's `plug :accepts`
// only whitelists "html", so setting accept: application/json triggers
// a 406. The default `*/*` matches fine and we still parse the body
// as JSON ourselves.
fetch(fetchUrl)
.then(resp => resp.json())
.then(contacts => {
this.allContacts = contacts
for (const c of contacts) this.enabledBands.add(c[4])
if (this.map) this.rebuildMap()
})
.catch(err => console.error("Failed to load contacts:", err))
},
initMap(this: ContactsMapHook) {
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: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
this.lineLayer.addTo(this.map)
this.dotLayer.addTo(this.map)
// If contacts arrived before the map was ready, draw them now.
if (this.allContacts.length > 0) {
for (const c of this.allContacts) this.enabledBands.add(c[4])
this.rebuildMap()
}
},
rebuildMap(this: ContactsMapHook) {
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: Record<string, number> = {}
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: L.Polyline) { this.setStyle({weight: 4, opacity: 1}) })
line.on("mouseout", function(this: L.Polyline) { 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)
}
}
}