- Add ContactMapController serving /api/contacts/map with pre-gzipped JSON from Microwaveprop.Cache. Browsers handle Content-Encoding: gzip natively; payload drops from 5.7 MB raw to 1.5 MB on the wire (73%). Cache TTL 10 min, invalidated on contact insert alongside the other contact-map cache keys. - Move contact map payload out of the LiveView's initial HTML entirely. Shell renders with the filter chrome (count + band list); the hook fetches contacts from the HTTP endpoint in parallel with map init. This also avoids Phoenix.Socket's 64 KB frame limit (push_event would have required chunked transfer or a global frame size bump). - Parallelize ContactLive.Show hydrate: five independent DB loads (weather, solar, hrrr_path, terrain, iemre) now run concurrently via Task.async + Task.await_many. Total latency drops from sum(each) to max(each), cutting contact show hydrate time roughly 5x.
182 lines
6.1 KiB
TypeScript
182 lines
6.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 {
|
|
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) {
|
|
// Contacts payload (~5 MB) comes from a standalone HTTP endpoint that
|
|
// serves pre-gzipped JSON. This keeps it out of the initial HTML and
|
|
// lets the browser cache it independently of the LiveView.
|
|
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
|
|
this.allContacts = []
|
|
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())
|
|
|
|
// Fetch contacts in parallel with map init. Once both are done,
|
|
// `rebuildMap()` draws everything. Starts earlier than a LiveView
|
|
// push_event because the HTTP request doesn't wait for the socket
|
|
// handshake.
|
|
// No explicit Accept header — the :browser pipeline's `plug :accepts`
|
|
// only whitelists "html", so setting accept: application/json triggers
|
|
// a 406. The default `*/*` matches fine and we still parse the body
|
|
// as JSON ourselves.
|
|
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.rebuildMap()
|
|
})
|
|
.catch(err => console.error("Failed to load contacts:", err))
|
|
},
|
|
|
|
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)
|
|
|
|
// If contacts arrived before the map was ready, draw them now.
|
|
if (this.allContacts.length > 0) {
|
|
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)
|
|
}
|
|
}
|
|
}
|