prop/assets/js/contacts_map_hook.ts
Graham McIntire 578b4ede86 Serve /contacts/map payload via HTTP endpoint, parallel hydrate
- 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.
2026-04-12 13:10:51 -05:00

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: "&copy; 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 || "?"} &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}) })
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)
}
}
}