- Rename all .js files to .ts in assets/js/ - Add interfaces for hook state, data structures, and event payloads - Add type annotations to function parameters and return types - Create type declarations for vendor deps (Leaflet, Chart.js, Phoenix, topbar) - Update tsconfig.json for strict TypeScript checking - Update esbuild entry point to app.ts
160 lines
5 KiB
TypeScript
160 lines
5 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 {
|
|
enabled_bands: number[]
|
|
callsign: string | null
|
|
}
|
|
|
|
interface ContactsMapHook extends ViewHook {
|
|
allContacts: ContactTuple[]
|
|
callsignFilter: string
|
|
enabledBands: Set<number>
|
|
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<number, string> = {
|
|
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) {
|
|
this.allContacts = JSON.parse(this.el.dataset.contacts!)
|
|
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())
|
|
},
|
|
|
|
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)
|
|
|
|
// Enable all bands initially
|
|
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<string, number> = {}
|
|
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(
|
|
`<div style="font-size:12px;line-height:1.5;">` +
|
|
`<strong>${s1 || "?"} ↔ ${s2 || "?"}</strong><br/>` +
|
|
`${bandLabel(band)} · ${mode || "?"}<br/>` +
|
|
`${distStr} · ${ts || "?"} UTC<br/>` +
|
|
`<a href="/contacts/${id}" style="color:#3b82f6;">View details →</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}) })
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|