towerops/assets/js/hooks/coverage_hooks.ts
Graham McIntire 4e391b3ecf feat(coverage): cnHeat-style full-bleed map on the show page
Rewire the single-coverage show page to mount MultiCoverageMap (the
same hook the org map uses) with one coverage in the array, and lift
the cnHeat-style overlay panels — LOS/NLOS toggle + height slider on
the left, opacity + RSSI filter on the bottom, distance probe on
click. Parameters move into a togglable right-side drawer so the map
dominates the viewport.

Show LiveView gains toggle_params / probe_point / close_probe /
set_probe_units handlers and delegates the formatters to
CoverageLive.Map. Site is now preloaded so coverages_payload/1 can
read site.name without an Ecto.AssociationNotLoaded crash that was
silently nuking the data-coverages JSON encode.

Also fixes a real bug in coverage_hooks.ts: pngForCoverage was
returning before the antenna-marker creation block, leaving the
marker code as unreachable dead code — towers never rendered. Moved
the marker creation into addCoverageLayer where it belongs and added
a no-PNG guard so a missing png_path logs instead of silently
failing the L.imageOverlay constructor.
2026-05-07 08:27:27 -05:00

863 lines
31 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
height_tiers: Record<string, string>
nlos_tiers: Record<string, string>
}
type CoverageMode = "los" | "nlos"
// SM install heights (ft) the worker pre-renders. Index = slider step.
const HEIGHT_TIERS_FT = [6, 10, 15, 20, 30, 40]
const HEIGHT_TIERS_M = HEIGHT_TIERS_FT.map((ft) => +(ft * 0.3048).toFixed(2))
type CoverageFilterState = {
bbox: [number, number, number, number]
pixels: ImageData
width: number
height: number
blobUrl: 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[]
filters: Map<string, CoverageFilterState>
rssiThreshold: number
heightTierIdx: number
mode: CoverageMode
opacityListener: ((e: Event) => void) | null
rssiListener: ((e: Event) => void) | null
heightListener: ((e: Event) => void) | null
modeListeners: Array<{ el: HTMLElement; fn: (e: Event) => void }>
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
bindRssiSlider: () => void
bindHeightSlider: () => void
bindModeToggle: () => void
bindBaseToggle: () => void
bindSearch: () => void
setMode: (mode: CoverageMode) => void
currentOpacity: () => number
pngForCoverage: (c: CoveragePayload) => string
placeProbeMarker: (lat: number, lon: number) => void
loadCoverageImage: (c: CoveragePayload) => Promise<void>
reapplyRssiFilter: () => void
rssiOverlayUrl: (state: CoverageFilterState) => string
}
// Palette → dBm centres used by Raster.write/3 (server-side gdaldem
// color-relief). We classify each pixel by nearest palette colour and
// drop those weaker than the slider threshold.
const RSSI_PALETTE: Array<{ r: number; g: number; b: number; dbm: number }> = [
{ r: 0, g: 200, b: 0, dbm: -50 },
{ r: 200, g: 230, b: 0, dbm: -65 },
{ r: 255, g: 165, b: 0, dbm: -75 },
{ r: 220, g: 60, b: 60, dbm: -85 },
{ r: 100, g: 0, b: 0, dbm: -95 },
]
function nearestPaletteDbm(r: number, g: number, b: number): number {
let best = RSSI_PALETTE[0]
let bestDist = Infinity
for (const p of RSSI_PALETTE) {
const dr = r - p.r
const dg = g - p.g
const db = b - p.b
const d = dr * dr + dg * dg + db * db
if (d < bestDist) {
bestDist = d
best = p
}
}
return best.dbm
}
export const MultiCoverageMap = {
map: null,
baseStreets: null,
baseSatellite: null,
overlays: new Map(),
markers: new Map(),
enabled: new Set(),
coverages: [],
filters: new Map(),
rssiThreshold: -100,
heightTierIdx: 1,
mode: "los",
opacityListener: null,
rssiListener: null,
heightListener: null,
modeListeners: [],
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.rssiListener) {
const slider = document.getElementById("coverage-rssi-slider")
if (slider) slider.removeEventListener("input", this.rssiListener)
this.rssiListener = null
}
if (this.heightListener) {
const slider = document.getElementById("coverage-height-slider")
if (slider) slider.removeEventListener("input", this.heightListener)
this.heightListener = null
}
this.modeListeners.forEach(({ el, fn }) => el.removeEventListener("click", fn))
this.modeListeners = []
if (this.searchListener) {
const input = document.getElementById("coverage-map-search")
if (input) input.removeEventListener("keydown", this.searchListener)
this.searchListener = null
}
this.filters.forEach((s) => {
if (s.blobUrl) URL.revokeObjectURL(s.blobUrl)
})
this.filters.clear()
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.bindRssiSlider()
this.bindHeightSlider()
this.bindModeToggle()
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 png = this.pngForCoverage(c)
if (!png) {
console.warn(`MultiCoverageMap: no PNG for coverage ${c.id}`)
return
}
const overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], {
opacity: this.currentOpacity(),
interactive: false,
})
this.overlays.set(c.id, overlay)
// Antenna marker (TX location with azimuth wedge).
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)
}
// Pre-decode the heatmap into ImageData so the RSSI slider can
// re-mask client-side without a server round-trip.
void this.loadCoverageImage(c)
},
// Picks the PNG URL for the currently-selected mode + SM-height
// tier, falling back through (mode tiers → other-mode tiers → the
// legacy single-height png) so older coverages still render.
pngForCoverage(this: MultiCoverageMapState, c: CoveragePayload): string {
const primary = this.mode === "los" ? c.height_tiers : c.nlos_tiers
const fallback = this.mode === "los" ? c.nlos_tiers : c.height_tiers
const tiers = (primary && Object.keys(primary).length > 0) ? primary : fallback
const targetM = HEIGHT_TIERS_M[this.heightTierIdx]
if (!tiers || Object.keys(tiers).length === 0) return c.png
const direct = tiers[String(targetM)] || tiers[targetM.toFixed(2)]
if (direct) return direct
// Fall back to the closest available tier by absolute distance.
let best: string | null = null
let bestDelta = Infinity
for (const [key, url] of Object.entries(tiers)) {
const m = parseFloat(key)
const delta = Math.abs(m - targetM)
if (delta < bestDelta) {
bestDelta = delta
best = url
}
}
return best || c.png
},
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)
},
bindRssiSlider(this: MultiCoverageMapState) {
const slider = document.getElementById("coverage-rssi-slider") as HTMLInputElement | null
if (!slider) return
this.rssiListener = (e: Event) => {
const target = e.target as HTMLInputElement
this.rssiThreshold = parseInt(target.value, 10)
const label = document.getElementById("coverage-rssi-label")
if (label) label.textContent = `${this.rssiThreshold} dBm`
this.reapplyRssiFilter()
}
slider.addEventListener("input", this.rssiListener)
},
// LOS / NLOS mode toggle. cnHeat-style: LOS treats clutter as
// ground baseline (predictions assume install-above-roof), NLOS
// treats clutter as obstacles (height-above-ground SM install).
bindModeToggle(this: MultiCoverageMapState) {
const buttons = ["coverage-mode-los", "coverage-mode-nlos"]
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
buttons.forEach((btn) => {
const fn = () => {
const mode = (btn.dataset.mode as CoverageMode) || "los"
this.setMode(mode)
}
btn.addEventListener("click", fn)
this.modeListeners.push({ el: btn, fn })
})
},
setMode(this: MultiCoverageMapState, mode: CoverageMode) {
if (mode === this.mode) return
this.mode = mode
const losBtn = document.getElementById("coverage-mode-los")
const nlosBtn = document.getElementById("coverage-mode-nlos")
const label = document.getElementById("coverage-mode-label")
const activeCls = ["text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40"]
const inactiveCls = ["text-gray-700", "dark:text-gray-300", "hover:bg-gray-100", "dark:hover:bg-gray-800"]
if (losBtn && nlosBtn) {
const active = mode === "los" ? losBtn : nlosBtn
const inactive = mode === "los" ? nlosBtn : losBtn
active.classList.add(...activeCls)
active.classList.remove(...inactiveCls)
inactive.classList.add(...inactiveCls)
inactive.classList.remove(...activeCls)
}
if (label) {
label.textContent = mode === "los" ? "Height above clutter" : "Height above ground"
}
this.filters.forEach((s) => {
if (s.blobUrl) URL.revokeObjectURL(s.blobUrl)
})
this.filters.clear()
this.coverages.forEach((c) => {
const overlay = this.overlays.get(c.id) as { setUrl: (url: string) => void } | undefined
if (overlay) overlay.setUrl(this.pngForCoverage(c))
void this.loadCoverageImage(c)
})
},
// Vertical SM-height slider (cnHeat-style). On change we re-fetch
// each coverage's PNG at the new tier, re-decode for the RSSI
// filter cache, and re-render. Cheap if the height_tiers map is
// populated; falls back to the legacy single-png otherwise.
bindHeightSlider(this: MultiCoverageMapState) {
const slider = document.getElementById("coverage-height-slider") as HTMLInputElement | null
if (!slider) return
this.heightListener = (e: Event) => {
const target = e.target as HTMLInputElement
const idx = parseInt(target.value, 10)
if (!Number.isFinite(idx) || idx < 0 || idx >= HEIGHT_TIERS_FT.length) return
this.heightTierIdx = idx
const ft = HEIGHT_TIERS_FT[idx]
const label = document.getElementById("coverage-height-label")
if (label) label.textContent = `${ft} ft`
// Drop the cached pixel data so the RSSI filter rebuilds from
// the new tier's image.
this.filters.forEach((s) => {
if (s.blobUrl) URL.revokeObjectURL(s.blobUrl)
})
this.filters.clear()
this.coverages.forEach((c) => {
const overlay = this.overlays.get(c.id) as { setUrl: (url: string) => void } | undefined
if (overlay) overlay.setUrl(this.pngForCoverage(c))
void this.loadCoverageImage(c)
})
}
slider.addEventListener("input", this.heightListener)
},
// Fetch the original PNG, decode it once into an ImageData, and
// store it so the slider can re-mask cheaply. We deliberately use
// crossOrigin="anonymous" — Phoenix serves the static assets from
// the same origin, but explicit opt-in keeps canvas reads working
// when CDNs ever come into play.
async loadCoverageImage(this: MultiCoverageMapState, c: CoveragePayload) {
try {
const img = new Image()
img.crossOrigin = "anonymous"
const loaded: Promise<HTMLImageElement> = new Promise((resolve, reject) => {
img.onload = () => resolve(img)
img.onerror = (e) => reject(e)
})
img.src = this.pngForCoverage(c)
await loaded
const canvas = document.createElement("canvas")
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.drawImage(img, 0, 0)
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height)
this.filters.set(c.id, {
bbox: c.bbox,
pixels,
width: canvas.width,
height: canvas.height,
blobUrl: null,
})
// If the user has already moved the RSSI slider before this
// image finished loading, apply the current threshold.
if (this.rssiThreshold > -100) {
this.reapplyRssiFilter()
}
} catch (err) {
console.error(`Failed to decode coverage PNG ${c.png}`, err)
}
},
// Re-applies the current RSSI threshold to every cached pixel
// buffer, swapping each L.imageOverlay's URL to a freshly-encoded
// blob. -100 dBm shows everything (no filtering needed → restore
// the original PNG URL for memory).
reapplyRssiFilter(this: MultiCoverageMapState) {
this.coverages.forEach((c) => {
const state = this.filters.get(c.id)
const overlay = this.overlays.get(c.id) as { setUrl: (url: string) => void } | undefined
if (!overlay) return
if (!state || this.rssiThreshold <= -100) {
// No threshold or pixel data not yet available — fall back
// to the original PNG.
overlay.setUrl(c.png)
return
}
const url = this.rssiOverlayUrl(state)
overlay.setUrl(url)
})
},
// Builds a filtered PNG blob URL from the cached ImageData. Pixels
// whose nearest palette colour has a dBm below the threshold are
// turned fully transparent. Releases the prior blob URL to avoid
// leaks.
rssiOverlayUrl(this: MultiCoverageMapState, state: CoverageFilterState): string {
if (state.blobUrl) URL.revokeObjectURL(state.blobUrl)
const filtered = new ImageData(state.width, state.height)
const src = state.pixels.data
const dst = filtered.data
const threshold = this.rssiThreshold
for (let i = 0; i < src.length; i += 4) {
const a = src[i + 3]
if (a === 0) {
dst[i + 3] = 0
continue
}
const dbm = nearestPaletteDbm(src[i], src[i + 1], src[i + 2])
if (dbm < threshold) {
dst[i + 3] = 0
} else {
dst[i] = src[i]
dst[i + 1] = src[i + 1]
dst[i + 2] = src[i + 2]
dst[i + 3] = a
}
}
const canvas = document.createElement("canvas")
canvas.width = state.width
canvas.height = state.height
const ctx = canvas.getContext("2d")
if (!ctx) return ""
ctx.putImageData(filtered, 0, 0)
state.blobUrl = canvas.toDataURL("image/png")
return state.blobUrl
},
}