prop/assets/js/contacts_map_hook.ts
Graham McIntire b8b78f7e57
feat(design): apply NTMS design system tokens
Adopt the handoff design system: navy-slate surfaces with indigo-violet
accent (#6366F1 dark / #5458E3 light), Inter as the UI font, hairline
1px borders, softer corner radii. Add propagation-scale and amateur-band
CSS vars so JS hooks can pull theme-aware colors via getComputedStyle.

Polish: drop table zebra striping for hover-on-base-200 rows with
uppercase small-caps headers, bump .header to text-2xl, tighten nav
spacing with a hairline divider, fix broken text-brand -> text-primary
on the auth screens.

Wire --band-* into contacts_map_hook: resolve once at mount, re-resolve
on data-theme mutation so polylines recolor when the theme toggles.
2026-04-17 17:34:15 -05:00

340 lines
11 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
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<number, string> = {
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<number, string> = {
68000: "#dc2626",
75000: "#c026d3",
122000: "#db2777",
134000: "#e11d48",
241000: "#b91c1c",
}
let BAND_COLORS: Record<number, string> | null = null
function resolveBandColors(): Record<number, string> {
if (BAND_COLORS) return BAND_COLORS
const styles = getComputedStyle(document.documentElement)
const resolved: Record<number, string> = { ...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<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
}
}