Closes the cnHeat-parity gap on modes and clutter:
Backend
- Towerops.Coverages.Buildings.for_bbox/1 — PostGIS ST_Intersects
query that returns each building polygon as a compact
%{height_m, coords} map ready for in-memory point-in-polygon
testing, so the per-pixel hot loop never round-trips to
Postgres.
- Towerops.Coverages.Profile.sample/5 — accepts an optional
:clutter list and adds the building rooftop height to terrain
whenever a sample point falls inside a polygon. Pure ray-cast
PIP for portability.
- Towerops.Workers.CoverageWorker — fetches buildings once per
job and computes BOTH LOS and NLOS variants per SM-height
tier (12 PNGs total). LOS passes clutter: [] (height-above-
clutter view), NLOS passes the prefetched list (height-above-
ground view with rooftop diffraction). Pixel-loop signature
bundled into a ctx map to keep the function arity within
Credo's max-8 limit.
- Towerops.Workers.MsBuildingsImportWorker — streams Microsoft
GlobalMLBuildingFootprints GeoJSONSeq exports line-by-line,
upserts into coverage_buildings in 500-row batches via
insert_all + ON CONFLICT (source, ms_footprint_id). Handles
Polygon and MultiPolygon geometries; stable-hashes the bbox
when an explicit feature id is missing so re-imports are
idempotent. Public import_file/2 lets ops drive a state at
a time from IEx.
- Schema: nlos_tiers JSONB column on coverages, status_changeset
whitelist, payload exposed via list_ready_for_organization.
Frontend
- LOS / NLOS toggle pills above the height slider (left panel).
Selecting NLOS swaps each L.imageOverlay's URL via setUrl to
the matching nlos_tiers PNG, drops the cached pixel data, and
re-decodes for the RSSI filter; falls back to the other
mode's tiers when one is empty so older coverages still
render. coverage_hooks chunk: 22 KB → 25 KB.
858 lines
31 KiB
TypeScript
858 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: '© <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: '© <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: '© <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 © 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)
|
|
const overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], {
|
|
opacity: this.currentOpacity(),
|
|
interactive: false,
|
|
})
|
|
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)
|
|
},
|
|
|
|
// 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
|
|
|
|
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)
|
|
}
|
|
},
|
|
|
|
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
|
|
},
|
|
}
|