towerops/assets/js/hooks/coverage_hooks.ts
Graham McIntire e97748437c build(assets): split coverage hooks into a lazy-loaded chunk
Switch esbuild to ESM with --splitting and load app.js as type=module.
Heavy hooks now ship in their own chunk and only download when their
LiveView mounts.

- assets/js/lib/leaflet.ts: extract ensureLeaflet (vendored Leaflet
  loader) into a shared module with a typed window.L declaration.
- assets/js/hooks/coverage_hooks.ts: extract CoverageMap,
  MultiCoverageMap, and CoverageLocationPicker (~510 lines) with
  proper interface types for hook context and tightened typing on
  payloads / event handlers (CoveragePayload is now a real type).
- app.ts: replace those three inline hook bodies with lazyHook
  stubs that call import('./hooks/coverage_hooks') on first mount,
  forwarding lifecycle hooks once the chunk resolves. Also adds a
  scoped `declare const L: any` so the remaining Leaflet hooks
  type-check.
- root.html.heex: switch the script tag to type=module so the
  emitted ESM bundle can use dynamic import().
- esbuild config: add --splitting --format=esm.

Build output now produces:
  app.js                      1.2 MB
  coverage_hooks-<hash>.js   16  KB  (lazy)
  chunk-<hash>.js             2.4 KB  (shared, lazy)
2026-05-06 16:11:32 -05:00

541 lines
20 KiB
TypeScript

// Coverage Leaflet hooks. Code-split off the main bundle and dynamically
// imported by app.ts only when one of these hooks mounts. Strict TS:
// the Leaflet runtime is treated as `unknown` via a single declared
// global, and our hooks expose explicit `LiveViewHook<S>` interfaces.
import { ensureLeaflet } from "../lib/leaflet"
// Leaflet's runtime types live on `window.L`. We only use a small
// surface so it's typed as `any` once at the boundary.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const L: any
// Phoenix LiveView's `this` inside a hook isn't directly exported from
// any package this project consumes, so define the shape we care about.
type LiveViewEl = HTMLElement & {
dataset: DOMStringMap
}
interface PhxHookContext {
el: LiveViewEl
pushEvent: (event: string, payload: Record<string, unknown>) => void
handleEvent: (event: string, cb: (payload: Record<string, unknown>) => void) => void
}
// CoverageLocationPicker — draggable marker on a small map for picking
// the radio's lat/lon during coverage form editing.
type CoverageLocationPickerState = PhxHookContext & {
map: any | null
marker: any | null
programmaticMove: boolean
initMap: () => void
syncMarkerFromAttrs: () => void
markerLat: () => number | null
markerLon: () => number | null
}
export const CoverageLocationPicker = {
map: null,
marker: null,
programmaticMove: false,
mounted(this: CoverageLocationPickerState) {
ensureLeaflet()
.then(() => this.initMap())
.catch((e) => console.error("CoverageLocationPicker: Leaflet failed to load", e))
},
updated(this: CoverageLocationPickerState) {
if (this.map) this.syncMarkerFromAttrs()
},
destroyed(this: CoverageLocationPickerState) {
if (this.map) {
this.map.remove()
this.map = null
this.marker = null
}
},
initMap(this: CoverageLocationPickerState) {
const lat = this.markerLat()
const lon = this.markerLon()
const hasFix = lat !== null && lon !== null
const center: [number, number] = hasFix ? [lat as number, lon as number] : [30.27, -97.74]
const zoom = hasFix ? 15 : 6
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true }).setView(center, zoom)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(this.map)
if (hasFix) {
this.marker = L.marker([lat as number, lon as number], { draggable: true, autoPan: true }).addTo(this.map)
this.marker.on("dragend", (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const ll = e.target.getLatLng()
this.pushEvent("location_picked", { lat: ll.lat, lon: ll.lng })
})
}
},
syncMarkerFromAttrs(this: CoverageLocationPickerState) {
const lat = this.markerLat()
const lon = this.markerLon()
if (lat === null || lon === null) return
if (!this.marker) {
this.marker = L.marker([lat, lon], { draggable: true, autoPan: true }).addTo(this.map)
this.marker.on("dragend", (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const ll = e.target.getLatLng()
this.pushEvent("location_picked", { lat: ll.lat, lon: ll.lng })
})
this.map.setView([lat, lon], 15)
return
}
const cur = this.marker.getLatLng()
if (Math.abs(cur.lat - lat) > 1e-7 || Math.abs(cur.lng - lon) > 1e-7) {
this.marker.setLatLng([lat, lon])
this.map.panTo([lat, lon])
}
},
markerLat(this: CoverageLocationPickerState): number | null {
const v = this.el.dataset.markerLat
if (!v) return null
const n = parseFloat(v)
return Number.isFinite(n) ? n : null
},
markerLon(this: CoverageLocationPickerState): number | null {
const v = this.el.dataset.markerLon
if (!v) return null
const n = parseFloat(v)
return Number.isFinite(n) ? n : null
},
}
// CoverageMap — single-coverage RSSI heatmap with antenna marker.
type CoverageMapState = PhxHookContext & {
map: any | null
marker: any | null
overlay: any | null
opacityListener: ((e: Event) => void) | null
init: () => void
refreshOverlay: () => void
bindOpacitySlider: () => void
currentOpacity: () => number
}
export const CoverageMap = {
map: null,
marker: null,
overlay: null,
opacityListener: null,
mounted(this: CoverageMapState) {
ensureLeaflet()
.then(() => this.init())
.catch((e) => console.error("CoverageMap: Leaflet failed to load", e))
},
updated(this: CoverageMapState) {
if (this.map) this.refreshOverlay()
},
destroyed(this: CoverageMapState) {
if (this.opacityListener) {
const slider = document.getElementById("coverage-opacity-slider")
if (slider) slider.removeEventListener("input", this.opacityListener)
this.opacityListener = null
}
if (this.map) {
this.map.remove()
this.map = null
this.marker = null
this.overlay = null
}
},
init(this: CoverageMapState) {
const lat = parseFloat(this.el.dataset.lat || "0")
const lon = parseFloat(this.el.dataset.lon || "0")
const azimuth = parseFloat(this.el.dataset.azimuth || "0")
const name = this.el.dataset.name || "Coverage"
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true }).setView([lat, lon], 13)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(this.map)
const antennaIcon = L.divIcon({
className: "coverage-antenna-marker",
html: `
<div style="position:relative;width:32px;height:32px">
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
transform:translate(-50%,-100%) rotate(${azimuth}deg);
transform-origin:50% 100%;
border-left:24px solid transparent;
border-right:24px solid transparent;
border-bottom:48px solid rgba(59,130,246,0.35);"></div>
<div style="position:absolute;top:50%;left:50%;width:14px;height:14px;
transform:translate(-50%,-50%);
background:#2563eb;border:2px solid white;border-radius:50%;
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
})
this.marker = L.marker([lat, lon], { icon: antennaIcon, title: name }).addTo(this.map)
this.refreshOverlay()
this.bindOpacitySlider()
},
refreshOverlay(this: CoverageMapState) {
const png = this.el.dataset.png
const bboxJson = this.el.dataset.bbox
if (this.overlay) {
this.map.removeLayer(this.overlay)
this.overlay = null
}
if (!png || !bboxJson) return
let bbox: number[]
try {
bbox = JSON.parse(bboxJson)
} catch {
return
}
if (!Array.isArray(bbox) || bbox.length !== 4) return
const [minLat, minLon, maxLat, maxLon] = bbox
const opacity = this.currentOpacity()
this.overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], {
opacity,
interactive: false,
}).addTo(this.map)
this.map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [20, 20] })
},
bindOpacitySlider(this: CoverageMapState) {
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
if (!slider) return
this.opacityListener = (e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10) / 100
if (this.overlay) this.overlay.setOpacity(v)
const label = document.getElementById("coverage-opacity-label")
if (label) label.textContent = `${Math.round(v * 100)}%`
}
slider.addEventListener("input", this.opacityListener)
},
currentOpacity(this: CoverageMapState): number {
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
if (!slider) return 0.7
return parseInt(slider.value, 10) / 100
},
}
// MultiCoverageMap — full-bleed cnHeat-style map showing every ready
// coverage in the org as a layered set of L.imageOverlays.
type CoveragePayload = {
id: string
name: string
png: string
bbox: [number, number, number, number]
tx: { lat: number | null; lon: number | null; azimuth: number | null }
site: string | null
}
type MultiCoverageMapState = PhxHookContext & {
map: any | null
baseStreets: any | null
baseSatellite: any | null
overlays: Map<string, unknown>
markers: Map<string, unknown>
enabled: Set<string>
coverages: CoveragePayload[]
opacityListener: ((e: Event) => void) | null
searchListener: ((e: KeyboardEvent) => void) | null
probeMarker: any | null
searchMarker: any | null
init: () => void
computeInitialView: () => { lat: number; lon: number; zoom: number }
injectToolbar: () => void
addCoverageLayer: (c: CoveragePayload) => void
applyEnabled: () => void
bindOpacitySlider: () => void
bindBaseToggle: () => void
bindSearch: () => void
currentOpacity: () => number
placeProbeMarker: (lat: number, lon: number) => void
}
export const MultiCoverageMap = {
map: null,
baseStreets: null,
baseSatellite: null,
overlays: new Map(),
markers: new Map(),
enabled: new Set(),
coverages: [],
opacityListener: null,
searchListener: null,
probeMarker: null,
searchMarker: null,
mounted(this: MultiCoverageMapState) {
ensureLeaflet()
.then(() => this.init())
.catch((e) => console.error("MultiCoverageMap: Leaflet failed to load", e))
},
destroyed(this: MultiCoverageMapState) {
if (this.opacityListener) {
const slider = document.getElementById("coverage-opacity-slider")
if (slider) slider.removeEventListener("input", this.opacityListener)
this.opacityListener = null
}
if (this.searchListener) {
const input = document.getElementById("coverage-map-search")
if (input) input.removeEventListener("keydown", this.searchListener)
this.searchListener = null
}
const toolbar = document.getElementById("coverage-map-toolbar")
if (toolbar) toolbar.remove()
if (this.map) {
this.map.remove()
this.map = null
this.overlays.clear()
this.markers.clear()
this.enabled.clear()
}
},
init(this: MultiCoverageMapState) {
this.coverages = JSON.parse(this.el.dataset.coverages || "[]") as CoveragePayload[]
const enabledList: string[] = JSON.parse(this.el.dataset.enabled || "[]")
this.enabled = new Set(enabledList)
const initial = this.computeInitialView()
this.injectToolbar()
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true, attributionControl: true }).setView(
[initial.lat, initial.lon],
initial.zoom
)
this.baseStreets = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
})
this.baseSatellite = L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{ attribution: "Tiles &copy; Esri", maxZoom: 19 }
)
this.baseSatellite.addTo(this.map)
this.coverages.forEach((c) => this.addCoverageLayer(c))
this.applyEnabled()
this.map.on("click", (e: { latlng: { lat: number; lng: number } }) => {
this.placeProbeMarker(e.latlng.lat, e.latlng.lng)
this.pushEvent("probe_point", { lat: e.latlng.lat, lon: e.latlng.lng })
})
this.bindOpacitySlider()
this.bindBaseToggle()
this.bindSearch()
this.handleEvent("coverage:enabled-changed", (payload) => {
this.enabled = new Set((payload as { enabled: string[] }).enabled)
this.applyEnabled()
})
this.handleEvent("coverage:list-changed", (payload) => {
const p = payload as { coverages: CoveragePayload[]; enabled: string[] }
this.overlays.forEach((o) => this.map.removeLayer(o))
this.markers.forEach((m) => this.map.removeLayer(m))
this.overlays.clear()
this.markers.clear()
this.coverages = p.coverages
this.enabled = new Set(p.enabled)
this.coverages.forEach((c) => this.addCoverageLayer(c))
this.applyEnabled()
})
},
computeInitialView(this: MultiCoverageMapState) {
if (this.coverages.length === 0) return { lat: 31.0, lon: -97.0, zoom: 6 }
const lats: number[] = []
const lons: number[] = []
this.coverages.forEach((c) => {
if (c.tx.lat !== null && c.tx.lon !== null) {
lats.push(c.tx.lat)
lons.push(c.tx.lon)
}
})
if (lats.length === 0) {
const [minLat, minLon, maxLat, maxLon] = this.coverages[0].bbox
return { lat: (minLat + maxLat) / 2, lon: (minLon + maxLon) / 2, zoom: 12 }
}
const lat = lats.reduce((s, v) => s + v, 0) / lats.length
const lon = lons.reduce((s, v) => s + v, 0) / lons.length
return { lat, lon, zoom: lats.length === 1 ? 13 : 11 }
},
injectToolbar(this: MultiCoverageMapState) {
if (document.getElementById("coverage-map-toolbar")) return
const toolbar = document.createElement("div")
toolbar.id = "coverage-map-toolbar"
toolbar.className =
"pointer-events-none fixed top-32 left-6 z-[1100] flex flex-col gap-2 w-72 max-w-[90vw]"
toolbar.innerHTML = `
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-2 flex gap-1">
<button type="button" id="coverage-base-streets" class="flex-1 px-2 py-1 text-xs rounded font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">Map</button>
<button type="button" id="coverage-base-satellite" class="flex-1 px-2 py-1 text-xs rounded font-medium text-gray-900 dark:text-white bg-blue-100 dark:bg-blue-900/40">Satellite</button>
</div>
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg">
<input type="text" id="coverage-map-search" placeholder="Search address..." class="w-full px-3 py-2 text-sm bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none" />
</div>
`
document.body.appendChild(toolbar)
},
addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) {
const [minLat, minLon, maxLat, maxLon] = c.bbox
const overlay = L.imageOverlay(c.png, [[minLat, minLon], [maxLat, maxLon]], {
opacity: this.currentOpacity(),
interactive: false,
})
this.overlays.set(c.id, overlay)
if (c.tx.lat !== null && c.tx.lon !== null) {
const az = c.tx.azimuth ?? 0
const icon = L.divIcon({
className: "coverage-antenna-marker",
html: `
<div style="position:relative;width:32px;height:32px">
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
transform:translate(-50%,-100%) rotate(${az}deg);
transform-origin:50% 100%;
border-left:18px solid transparent;
border-right:18px solid transparent;
border-bottom:36px solid rgba(59,130,246,0.35);"></div>
<div style="position:absolute;top:50%;left:50%;width:12px;height:12px;
transform:translate(-50%,-50%);
background:#2563eb;border:2px solid white;border-radius:50%;
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
})
const marker = L.marker([c.tx.lat, c.tx.lon], { icon, title: c.name })
this.markers.set(c.id, marker)
}
},
applyEnabled(this: MultiCoverageMapState) {
this.coverages.forEach((c) => {
const overlay = this.overlays.get(c.id) as any | undefined
const marker = this.markers.get(c.id) as any | undefined
const on = this.enabled.has(c.id)
if (overlay) {
if (on && !this.map.hasLayer(overlay)) overlay.addTo(this.map)
if (!on && this.map.hasLayer(overlay)) this.map.removeLayer(overlay)
}
if (marker) {
if (on && !this.map.hasLayer(marker)) marker.addTo(this.map)
if (!on && this.map.hasLayer(marker)) this.map.removeLayer(marker)
}
})
},
bindOpacitySlider(this: MultiCoverageMapState) {
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
if (!slider) return
this.opacityListener = (e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10) / 100
this.overlays.forEach((o) => (o as any).setOpacity(v))
const label = document.getElementById("coverage-opacity-label")
if (label) label.textContent = `${Math.round(v * 100)}%`
}
slider.addEventListener("input", this.opacityListener)
},
currentOpacity(this: MultiCoverageMapState): number {
const slider = document.getElementById("coverage-opacity-slider") as HTMLInputElement | null
if (!slider) return 0.7
return parseInt(slider.value, 10) / 100
},
bindBaseToggle(this: MultiCoverageMapState) {
const streets = document.getElementById("coverage-base-streets")
const satellite = document.getElementById("coverage-base-satellite")
if (!streets || !satellite) return
streets.addEventListener("click", () => {
if (this.map.hasLayer(this.baseSatellite)) this.map.removeLayer(this.baseSatellite)
if (!this.map.hasLayer(this.baseStreets)) this.baseStreets.addTo(this.map)
streets.classList.add("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
satellite.classList.remove("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
})
satellite.addEventListener("click", () => {
if (this.map.hasLayer(this.baseStreets)) this.map.removeLayer(this.baseStreets)
if (!this.map.hasLayer(this.baseSatellite)) this.baseSatellite.addTo(this.map)
satellite.classList.add("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
streets.classList.remove("text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40")
})
},
bindSearch(this: MultiCoverageMapState) {
const input = document.getElementById("coverage-map-search") as HTMLInputElement | null
if (!input) return
this.searchListener = async (e: KeyboardEvent) => {
if (e.key !== "Enter") return
const q = input.value.trim()
if (!q) return
try {
const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}`
const res = await fetch(url, { headers: { Accept: "application/json" } })
if (!res.ok) return
const data = (await res.json()) as Array<{ lat: string; lon: string; display_name: string }>
if (!data || data.length === 0) return
const lat = parseFloat(data[0].lat)
const lon = parseFloat(data[0].lon)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return
this.map.setView([lat, lon], 16)
if (this.searchMarker) this.map.removeLayer(this.searchMarker)
this.searchMarker = L.marker([lat, lon]).addTo(this.map).bindPopup(data[0].display_name).openPopup()
} catch (err) {
console.error("Coverage search failed:", err)
}
}
input.addEventListener("keydown", this.searchListener)
},
placeProbeMarker(this: MultiCoverageMapState, lat: number, lon: number) {
if (this.probeMarker) this.map.removeLayer(this.probeMarker)
const icon = L.divIcon({
className: "coverage-probe-marker",
html: `<div style="width:14px;height:14px;border-radius:50%;background:#dc2626;border:3px solid white;box-shadow:0 0 0 1px rgba(0,0,0,0.4)"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7],
})
this.probeMarker = L.marker([lat, lon], { icon, interactive: false }).addTo(this.map)
},
}