feat(coverage): client-side signal-strength filter slider

Adds the cnHeat-style horizontal 'Min RSSI' slider beside the opacity
control. Pixels weaker than the threshold turn fully transparent,
client-side, with no server round-trip — same compute output now
powers an interactive 'where can I get -75 dBm or better' exploration.

- Slider range -100..-40 dBm step 1; -100 disables filtering.
- addCoverageLayer fetches each PNG into a hidden canvas and caches
  its ImageData. crossOrigin=anonymous keeps reads tainted-free.
- Each pixel is classified by nearest palette colour (-50/-65/-75/
  -85/-95 dBm centres matching Raster.write/3) and dropped if its
  dBm is below the threshold. Result re-encoded via toDataURL and
  swapped onto the L.imageOverlay via setUrl.
- destroyed() revokes blob URLs and clears the filter cache to
  avoid leaks during LiveView nav. Bundle: coverage_hooks chunk
  16 KB → 20 KB; main bundle unchanged at 1.2 MB.
This commit is contained in:
Graham McIntire 2026-05-06 16:29:24 -05:00
parent f77c493fa3
commit 5d1adcaacc
2 changed files with 194 additions and 0 deletions

View file

@ -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<string, unknown>
enabled: Set<string>
coverages: CoveragePayload[]
filters: Map<string, CoverageFilterState>
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<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 = {
@ -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<HTMLImageElement> = 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
},
}

View file

@ -129,6 +129,25 @@
/>
<span id="coverage-opacity-label" class="text-xs font-mono w-10 text-right">70%</span>
</div>
<%!-- Min signal-strength filter — hides pixels weaker than this. --%>
<div class="flex items-center gap-3">
<label
for="coverage-rssi-slider"
class="text-xs font-medium text-gray-700 dark:text-gray-300 w-16"
>
{t("Min RSSI")}
</label>
<input
id="coverage-rssi-slider"
type="range"
min="-100"
max="-40"
step="1"
value="-100"
class="flex-1 cursor-pointer"
/>
<span id="coverage-rssi-label" class="text-xs font-mono w-16 text-right">-100 dBm</span>
</div>
<div class="flex items-center gap-1 text-[10px] font-mono text-gray-700 dark:text-gray-300">
<span class="w-3 h-3 rounded inline-block" style="background:rgb(0,200,0)"></span>
<span>≥-50</span>