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 LineEntry { line: L.Polyline band: number s1: string s2: string lat1: number lon1: number lat2: number lon2: number } interface ContactsMapHook extends ViewHook { allContacts: ContactTuple[] callsignFilter: string enabledBands: Set bandGroups: Map lines: LineEntry[] dotLayer: L.LayerGroup map: L.Map delegatedChange: (e: Event) => void delegatedClick: (e: Event) => void themeObserver: MutationObserver | null 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 restyleLines(this: ContactsMapHook): void } // Amateur-band palette. Nine of these map to the design system's --band-* // CSS vars (defined in assets/css/app.css); the higher mm-wave bands above // 47 GHz fall back to hard-coded hues since the design set only spans up // to 6mm. CSS vars are resolved once per page via getComputedStyle because // Leaflet writes the color onto the SVG `stroke` attribute, which does not // evaluate `var()` references. const BAND_VAR: Record = { 1296: "--band-23cm", 2304: "--band-13cm", 3400: "--band-9cm", 5760: "--band-6cm", 10000: "--band-3cm", 24000: "--band-1-25cm", 47000: "--band-6mm", } const BAND_FALLBACK: Record = { 68000: "#dc2626", 75000: "#c026d3", 122000: "#db2777", 134000: "#e11d48", 241000: "#b91c1c", } let BAND_COLORS: Record | null = null function resolveBandColors(): Record { if (BAND_COLORS) return BAND_COLORS const styles = getComputedStyle(document.documentElement) const resolved: Record = { ...BAND_FALLBACK } for (const [band, cssVar] of Object.entries(BAND_VAR)) { const value = styles.getPropertyValue(cssVar).trim() if (value) resolved[Number(band)] = value } BAND_COLORS = resolved return resolved } function invalidateBandColors(): void { BAND_COLORS = null } 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.enabledBands = new Set() this.bandGroups = new Map() this.lines = [] this.dotLayer = L.layerGroup() this.themeObserver = null // 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.wireControls() // Re-color polylines when the theme toggles. Leaflet writes `stroke` as // an SVG attribute (which does not resolve CSS vars), so we re-read the // resolved --band-* values and call setStyle on each line. this.themeObserver = new MutationObserver(() => { invalidateBandColors() this.restyleLines() }) this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }) 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() if (this.themeObserver) { this.themeObserver.disconnect() this.themeObserver = null } }, restyleLines(this: ContactsMapHook) { for (const entry of this.lines) { entry.line.setStyle({ color: bandColor(entry.band) }) } }, 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(), 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 filter: iterate individual polylines and add/remove them // from their band's group. Cheap compared to rebuild (no polyline // creation). Only runs when the filter string changes. const match = this.callsignFilter for (const entry of this.lines) { const group = this.bandGroups.get(entry.band) if (!group) continue const visible = !match || entry.s1.includes(match) || entry.s2.includes(match) 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 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 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 } }