prop/assets/js/contacts_map_hook.ts
Graham McIntire 6972feb2c2
perf(contact-map): client-side band filtering via per-band layer groups
Band toggles previously roundtripped through LiveView and rebuilt every
polyline + popup + hover handler on each click. Now the hook builds one
L.LayerGroup per band once after the initial fetch; toggling is just
addLayer/removeLayer on the map (O(bands), not O(contacts)). Checkbox
and All/None events are handled directly in the hook via delegated
document listeners — no LV roundtrip. Callsign filter still roundtrips
(debounced) since it's cross-cutting, and now iterates pre-built
polylines without recreating them.
2026-04-17 12:08:46 -05:00

288 lines
9.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 {
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<number>
bandGroups: Map<number, L.LayerGroup>
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
}
const BAND_COLORS: Record<number, string> = {
1296: "#475569",
2304: "#7c3aed",
3400: "#4f46e5",
5760: "#2563eb",
10000: "#059669",
24000: "#d97706",
47000: "#ea580c",
68000: "#dc2626",
75000: "#c026d3",
122000: "#db2777",
134000: "#e11d48",
241000: "#b91c1c",
}
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) {
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()
// 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()
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<HTMLInputElement>("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: "&copy; 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 = 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}) })
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<string, number> = {}
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
}
}