// 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` 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) => void handleEvent: (event: string, cb: (payload: Record) => 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: '© OpenStreetMap 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: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(this.map) const antennaIcon = L.divIcon({ className: "coverage-antenna-marker", html: `
`, 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 nlos_tiers: Record } 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 markers: Map enabled: Set coverages: CoveragePayload[] filters: Map 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 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: '© OpenStreetMap contributors', maxZoom: 19, }) this.baseSatellite = L.tileLayer( "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { attribution: "Tiles © 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 = `
` 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: `
`, 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) // Bind via a text node, not an HTML string — Nominatim's display_name // is third-party data and would otherwise execute as HTML/JS in the popup. const popupEl = document.createElement("div") popupEl.textContent = data[0].display_name this.searchMarker = L.marker([lat, lon]).addTo(this.map).bindPopup(popupEl).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: `
`, 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 = 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 }, }