// 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 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 = { 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: 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: "© 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 = {} 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: 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) } } }