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:
parent
f77c493fa3
commit
5d1adcaacc
2 changed files with 194 additions and 0 deletions
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue