diff --git a/assets/js/hooks/coverage_hooks.ts b/assets/js/hooks/coverage_hooks.ts index a91301b7..6e331478 100644 --- a/assets/js/hooks/coverage_hooks.ts +++ b/assets/js/hooks/coverage_hooks.ts @@ -259,6 +259,14 @@ type CoveragePayload = { site: string | null } +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 @@ -267,7 +275,10 @@ type MultiCoverageMapState = PhxHookContext & { markers: Map enabled: Set coverages: CoveragePayload[] + filters: Map + rssiThreshold: number opacityListener: ((e: Event) => void) | null + rssiListener: ((e: Event) => void) | null searchListener: ((e: KeyboardEvent) => void) | null probeMarker: any | null searchMarker: any | null @@ -277,10 +288,41 @@ type MultiCoverageMapState = PhxHookContext & { addCoverageLayer: (c: CoveragePayload) => void applyEnabled: () => void bindOpacitySlider: () => void + bindRssiSlider: () => void bindBaseToggle: () => void bindSearch: () => void currentOpacity: () => number 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 = { @@ -291,7 +333,10 @@ export const MultiCoverageMap = { markers: new Map(), enabled: new Set(), coverages: [], + filters: new Map(), + rssiThreshold: -100, opacityListener: null, + rssiListener: null, searchListener: null, probeMarker: null, searchMarker: null, @@ -308,11 +353,20 @@ export const MultiCoverageMap = { 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.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) { @@ -357,6 +411,7 @@ export const MultiCoverageMap = { }) this.bindOpacitySlider() + this.bindRssiSlider() this.bindBaseToggle() this.bindSearch() @@ -424,6 +479,10 @@ export const MultiCoverageMap = { }) this.overlays.set(c.id, overlay) + // Pre-decode the heatmap into ImageData so the RSSI slider can + // re-mask client-side without a server round-trip. + void this.loadCoverageImage(c) + if (c.tx.lat !== null && c.tx.lon !== null) { const az = c.tx.azimuth ?? 0 const icon = L.divIcon({ @@ -538,4 +597,120 @@ export const MultiCoverageMap = { }) 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) + }, + + // 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 = c.png + 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 + }, } diff --git a/lib/towerops_web/live/coverage_live/map.html.heex b/lib/towerops_web/live/coverage_live/map.html.heex index 3421cc72..43d65e97 100644 --- a/lib/towerops_web/live/coverage_live/map.html.heex +++ b/lib/towerops_web/live/coverage_live/map.html.heex @@ -129,6 +129,25 @@ /> 70% + <%!-- Min signal-strength filter — hides pixels weaker than this. --%> +
+ + + -100 dBm +
≥-50