import {formatDistanceKm} from "./format" // 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 { callsign: string | null } interface ApplyDateFilterPayload { start: string | null end: string | null } interface LineEntry { line: L.Polyline band: number s1: string s2: string tsDate: string lat1: number lon1: number lat2: number lon2: number } interface ContactsMapHook extends ViewHook { allContacts: ContactTuple[] callsignFilter: string startDate: string endDate: string enabledBands: Set bandGroups: Map lines: LineEntry[] dotLayer: L.LayerGroup map: L.Map delegatedChange: (e: Event) => void delegatedClick: (e: Event) => void initMap(this: ContactsMapHook): void buildLines(this: ContactsMapHook): void syncBandLayers(this: ContactsMapHook): void applyCallsignFilter(this: ContactsMapHook): void rebuildDots(this: ContactsMapHook): void updateCount(this: ContactsMapHook, count: number): void wireControls(this: ContactsMapHook): void unwireControls(this: ContactsMapHook): void } function inDateRange(tsDate: string, startDate: string, endDate: string): boolean { if (!startDate && !endDate) return true if (!tsDate) return false if (startDate && tsDate < startDate) return false if (endDate && tsDate > endDate) return false return true } // Band colors are sourced from the legend swatches in the sidebar — each // band checkbox carries `data-band-color` set server-side from // `Microwaveprop.ContactMapLive`'s @band_colors map. Reading from the DOM // keeps the legend the single source of truth: change the Elixir map and // both the swatch and the polyline update in lockstep. let BAND_COLORS: Record | null = null function resolveBandColors(): Record { if (BAND_COLORS) return BAND_COLORS const resolved: Record = {} const inputs = document.querySelectorAll("input[data-band][data-band-color]") for (const inp of inputs) { const band = parseInt(inp.dataset.band || "", 10) const color = inp.dataset.bandColor if (Number.isFinite(band) && color) resolved[band] = color } BAND_COLORS = resolved return resolved } 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 resolveBandColors()[band] || "#64748b" } export const ContactsMap = { mounted(this: ContactsMapHook) { const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map" this.allContacts = [] this.callsignFilter = "" this.startDate = "" this.endDate = "" this.enabledBands = new Set() this.bandGroups = new Map() this.lines = [] this.dotLayer = L.layerGroup() // Callsign filter still roundtrips through LiveView (debounced) — it's // cross-cutting and fires rarely. Band toggles are purely client-side. this.handleEvent("apply_filter", ({callsign}: ApplyFilterPayload) => { this.callsignFilter = (callsign || "").toUpperCase() this.applyCallsignFilter() this.rebuildDots() }) this.handleEvent("apply_date_filter", ({start, end}: ApplyDateFilterPayload) => { this.startDate = start || "" this.endDate = end || "" this.applyCallsignFilter() this.rebuildDots() }) this.wireControls() requestAnimationFrame(() => this.initMap()) 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.buildLines() this.syncBandLayers() this.rebuildDots() } }) .catch(err => console.error("Failed to load contacts:", err)) }, destroyed(this: ContactsMapHook) { this.unwireControls() }, wireControls(this: ContactsMapHook) { // Band checkboxes and All/None buttons live outside this hook's element // (they're in the sidebar), so delegate from the document. The checkboxes // have data-band and the bulk buttons have data-band-action="all"|"none". this.delegatedChange = (e: Event) => { const t = e.target as HTMLInputElement if (!t || !t.matches || !t.matches("input[data-band]")) return const band = parseInt(t.dataset.band || "", 10) if (!Number.isFinite(band)) return if (t.checked) this.enabledBands.add(band) else this.enabledBands.delete(band) this.syncBandLayers() this.rebuildDots() } this.delegatedClick = (e: Event) => { const t = e.target as HTMLElement if (!t || !t.matches || !t.matches("[data-band-action]")) return const action = t.dataset.bandAction const inputs = document.querySelectorAll("input[data-band]") if (action === "all") { this.enabledBands = new Set() for (const inp of inputs) { inp.checked = true const band = parseInt(inp.dataset.band || "", 10) if (Number.isFinite(band)) this.enabledBands.add(band) } } else if (action === "none") { this.enabledBands = new Set() for (const inp of inputs) inp.checked = false } else { return } this.syncBandLayers() this.rebuildDots() } document.addEventListener("change", this.delegatedChange) document.addEventListener("click", this.delegatedClick) }, unwireControls(this: ContactsMapHook) { if (this.delegatedChange) document.removeEventListener("change", this.delegatedChange) if (this.delegatedClick) document.removeEventListener("click", this.delegatedClick) }, 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.dotLayer.addTo(this.map) if (this.allContacts.length > 0) { for (const c of this.allContacts) this.enabledBands.add(c[4]) this.buildLines() this.syncBandLayers() this.rebuildDots() } }, buildLines(this: ContactsMapHook) { // One-time build of every polyline, grouped into per-band LayerGroups. // After this, band toggling is just addLayer/removeLayer on the map — // no polyline recreation ever. this.lines = [] this.bandGroups = new Map() for (const c of this.allContacts) { 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 = formatDistanceKm(dist) 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}) }) let group = this.bandGroups.get(band) if (!group) { group = L.layerGroup() this.bandGroups.set(band, group) } group.addLayer(line) this.lines.push({ line, band, s1: (s1 || "").toUpperCase(), s2: (s2 || "").toUpperCase(), // Date inputs come in as YYYY-MM-DD; ts is "YYYY-MM-DD HH:MM" so // pre-slicing the date portion lets the range check be a string // compare without having to substring per-line every render. tsDate: (ts || "").substring(0, 10), lat1, lon1, lat2, lon2 }) } }, syncBandLayers(this: ContactsMapHook) { // Fast path: flip whole-group on/off. O(bands), not O(contacts). for (const [band, group] of this.bandGroups) { const enabled = this.enabledBands.has(band) const present = this.map.hasLayer(group) if (enabled && !present) this.map.addLayer(group) else if (!enabled && present) this.map.removeLayer(group) } }, applyCallsignFilter(this: ContactsMapHook) { // Callsign + date filter: iterate individual polylines and add/remove // them from their band's group. Cheap compared to rebuild (no polyline // creation). Runs when either filter changes. const match = this.callsignFilter const startDate = this.startDate const endDate = this.endDate for (const entry of this.lines) { const group = this.bandGroups.get(entry.band) if (!group) continue const callsignOk = !match || entry.s1.includes(match) || entry.s2.includes(match) const dateOk = inDateRange(entry.tsDate, startDate, endDate) const visible = callsignOk && dateOk const present = group.hasLayer(entry.line) if (visible && !present) group.addLayer(entry.line) else if (!visible && present) group.removeLayer(entry.line) } }, rebuildDots(this: ContactsMapHook) { this.dotLayer.clearLayers() const match = this.callsignFilter const startDate = this.startDate const endDate = this.endDate const endpointCounts: Record = {} let lineCount = 0 for (const entry of this.lines) { if (!this.enabledBands.has(entry.band)) continue if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue if (!inDateRange(entry.tsDate, startDate, endDate)) continue lineCount++ const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}` const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}` endpointCounts[k1] = (endpointCounts[k1] || 0) + 1 endpointCounts[k2] = (endpointCounts[k2] || 0) + 1 } 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) } this.updateCount(lineCount) }, updateCount(this: ContactsMapHook, count: number) { const label = `${count.toLocaleString()} contacts` const countEl = document.getElementById("contact-count") if (countEl) countEl.textContent = label const countMobile = document.getElementById("contact-count-mobile") if (countMobile) countMobile.textContent = label } }