feat(coverage): multi-SM-height compute + cnHeat-style height slider

Vertical 'Height above clutter' slider on the left of the map now
matches cnHeat's experience: pick an SM install height, the heatmap
re-renders to show only the coverage achievable from that height.

Backend
- New JSONB height_tiers column on coverages keyed by string metres
  → relative PNG path.
- CoverageWorker runs compute_pixels once per tier in
  [1.83, 3.05, 4.57, 6.10, 9.14, 12.19] m (~6/10/15/20/30/40 ft)
  by varying receiver_height_m on a copied struct. Each tier
  writes its own raster + PNG via Raster.write/4 with a per-height
  filename suffix; the default 3.05 m tier also populates the
  legacy raster_path / png_path so existing show pages keep
  working.

Frontend
- Vertical range slider (writing-mode: vertical-lr) anchored
  centre-left. Step picks one of HEIGHT_TIERS_FT; label updates
  to '6 ft'..'40 ft'.
- pngForCoverage/2 picks the closest available tier from the
  payload's height_tiers map, falling back to the original png
  for coverages not yet recomputed.
- On slider change the JS swaps each L.imageOverlay's URL via
  setUrl, drops the cached pixel data, and re-decodes so the RSSI
  filter still applies. coverage_hooks chunk: 20 KB → 22 KB.
This commit is contained in:
Graham McIntire 2026-05-06 16:38:07 -05:00
parent 5d1adcaacc
commit 434f5782d0
7 changed files with 204 additions and 24 deletions

View file

@ -257,8 +257,13 @@ type CoveragePayload = {
bbox: [number, number, number, number]
tx: { lat: number | null; lon: number | null; azimuth: number | null }
site: string | null
height_tiers: Record<string, string>
}
// 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
@ -277,8 +282,10 @@ type MultiCoverageMapState = PhxHookContext & {
coverages: CoveragePayload[]
filters: Map<string, CoverageFilterState>
rssiThreshold: number
heightTierIdx: number
opacityListener: ((e: Event) => void) | null
rssiListener: ((e: Event) => void) | null
heightListener: ((e: Event) => void) | null
searchListener: ((e: KeyboardEvent) => void) | null
probeMarker: any | null
searchMarker: any | null
@ -289,9 +296,11 @@ type MultiCoverageMapState = PhxHookContext & {
applyEnabled: () => void
bindOpacitySlider: () => void
bindRssiSlider: () => void
bindHeightSlider: () => void
bindBaseToggle: () => void
bindSearch: () => void
currentOpacity: () => number
pngForCoverage: (c: CoveragePayload) => string
placeProbeMarker: (lat: number, lon: number) => void
loadCoverageImage: (c: CoveragePayload) => Promise<void>
reapplyRssiFilter: () => void
@ -335,8 +344,10 @@ export const MultiCoverageMap = {
coverages: [],
filters: new Map(),
rssiThreshold: -100,
heightTierIdx: 1,
opacityListener: null,
rssiListener: null,
heightListener: null,
searchListener: null,
probeMarker: null,
searchMarker: null,
@ -358,6 +369,11 @@ export const MultiCoverageMap = {
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
}
if (this.searchListener) {
const input = document.getElementById("coverage-map-search")
if (input) input.removeEventListener("keydown", this.searchListener)
@ -412,6 +428,7 @@ export const MultiCoverageMap = {
this.bindOpacitySlider()
this.bindRssiSlider()
this.bindHeightSlider()
this.bindBaseToggle()
this.bindSearch()
@ -473,7 +490,8 @@ export const MultiCoverageMap = {
addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) {
const [minLat, minLon, maxLat, maxLon] = c.bbox
const overlay = L.imageOverlay(c.png, [[minLat, minLon], [maxLat, maxLon]], {
const png = this.pngForCoverage(c)
const overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], {
opacity: this.currentOpacity(),
interactive: false,
})
@ -482,6 +500,31 @@ export const MultiCoverageMap = {
// 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 SM-height tier,
// falling back to the legacy single-height png when the coverage
// hasn't been recomputed since the multi-tier worker shipped.
pngForCoverage(this: MultiCoverageMapState, c: CoveragePayload): string {
const tiers = c.height_tiers || {}
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
@ -611,6 +654,38 @@ export const MultiCoverageMap = {
slider.addEventListener("input", this.rssiListener)
},
// 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
@ -624,7 +699,7 @@ export const MultiCoverageMap = {
img.onload = () => resolve(img)
img.onerror = (e) => reject(e)
})
img.src = c.png
img.src = this.pngForCoverage(c)
await loaded
const canvas = document.createElement("canvas")

View file

@ -72,6 +72,12 @@ defmodule Towerops.Coverages.Coverage do
field :raster_path, :string
field :png_path, :string
# Map of receiver-height-in-metres (string key) → relative PNG path.
# Populated by the worker for cnHeat-style "what coverage do I get
# at SM height X?" exploration. Always includes the default tier
# whose path also lives at `png_path`.
field :height_tiers, :map, default: %{}
belongs_to :site, Site
belongs_to :organization, Organization
belongs_to :device, Device
@ -220,7 +226,8 @@ defmodule Towerops.Coverages.Coverage do
:bbox_min_lon,
:bbox_max_lon,
:raster_path,
:png_path
:png_path,
:height_tiers
])
|> validate_inclusion(:status, @statuses)
|> validate_number(:progress_pct, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)

View file

@ -38,30 +38,39 @@ defmodule Towerops.Coverages.Raster do
row and `nrows` rows total. `bbox` is `{min_lat, min_lon, max_lat,
max_lon}` in WGS84.
"""
@spec write(Coverage.t(), [number() | :nan], %{
ncols: pos_integer(),
nrows: pos_integer(),
bbox: {number(), number(), number(), number()}
}) ::
@spec write(
Coverage.t(),
[number() | :nan],
%{
ncols: pos_integer(),
nrows: pos_integer(),
bbox: {number(), number(), number(), number()}
},
String.t()
) ::
{:ok, %{tif: String.t(), png: String.t(), bbox: tuple()}} | {:error, term()}
def write(%Coverage{} = coverage, pixels, %{ncols: ncols, nrows: nrows, bbox: bbox}) when is_list(pixels) do
def write(coverage, pixels, dims, suffix \\ "")
def write(%Coverage{} = coverage, pixels, %{ncols: ncols, nrows: nrows, bbox: bbox}, suffix)
when is_list(pixels) and is_binary(suffix) do
expected = ncols * nrows
if length(pixels) == expected do
do_write(coverage, pixels, ncols, nrows, bbox)
do_write(coverage, pixels, ncols, nrows, bbox, suffix)
else
{:error, {:pixel_count_mismatch, %{expected: expected, got: length(pixels)}}}
end
end
defp do_write(coverage, pixels, ncols, nrows, {min_lat, min_lon, max_lat, max_lon} = bbox) do
defp do_write(coverage, pixels, ncols, nrows, {min_lat, min_lon, max_lat, max_lon} = bbox, suffix) do
out_dir = output_dir(coverage)
File.mkdir_p!(out_dir)
raw_path = Path.join(out_dir, "rssi.f32")
vrt_path = Path.join(out_dir, "rssi.vrt")
tif_path = Path.join(out_dir, "rssi.tif")
png_path = Path.join(out_dir, "rssi.png")
base = "rssi" <> suffix
raw_path = Path.join(out_dir, base <> ".f32")
vrt_path = Path.join(out_dir, base <> ".vrt")
tif_path = Path.join(out_dir, base <> ".tif")
png_path = Path.join(out_dir, base <> ".png")
palette_path = Path.join(out_dir, "palette.txt")
with :ok <- write_raw(raw_path, pixels),
@ -75,8 +84,8 @@ defmodule Towerops.Coverages.Raster do
{:ok,
%{
tif: relative_static_path(coverage, "rssi.tif"),
png: relative_static_path(coverage, "rssi.png"),
tif: relative_static_path(coverage, base <> ".tif"),
png: relative_static_path(coverage, base <> ".png"),
bbox: bbox
}}
end

View file

@ -44,6 +44,12 @@ defmodule Towerops.Workers.CoverageWorker do
# trade-off for WISP-scale paths.
@profile_samples 64
# SM (Subscriber Module) install heights to compute. Mirrors the
# cnHeat vertical slider granularity — a WISP-realistic ladder
# spanning ground-mount through tower-mount.
@sm_height_tiers_m [1.83, 3.05, 4.57, 6.10, 9.14, 12.19]
@default_tier_m 3.05
@impl Oban.Worker
def perform(%Oban.Job{args: %{"coverage_id" => coverage_id, "organization_id" => organization_id}}) do
case Repo.get(Coverage, coverage_id) do
@ -73,16 +79,53 @@ defmodule Towerops.Workers.CoverageWorker do
{:ok, bbox} <- compute_bbox(coverage, lat, lon),
_ = update_progress(coverage, "computing", 5),
{:ok, grid} <- fetch_terrain(bbox, coverage.cell_size_m, lat),
_ = update_progress(coverage, "computing", 30),
{:ok, output} <- compute_pixels(coverage, antenna, lat, lon, bbox, grid),
_ = update_progress(coverage, "computing", 90),
{:ok, paths} <- Raster.write(coverage, output.pixels, output.dims) do
finalize(coverage, paths)
_ = update_progress(coverage, "computing", 15),
{:ok, tiers} <- compute_all_tiers(coverage, antenna, lat, lon, bbox, grid) do
finalize(coverage, tiers)
else
{:error, reason} -> fail(coverage, reason)
end
end
# Runs compute_pixels once per SM-height tier and writes a
# per-tier raster + PNG. Returns a map keyed by the same data the
# finalize step persists onto the coverage row.
defp compute_all_tiers(coverage, antenna, lat, lon, bbox, grid) do
total = length(@sm_height_tiers_m)
indexed = Enum.with_index(@sm_height_tiers_m)
{results, error} =
Enum.reduce_while(indexed, {%{}, nil}, fn {height_m, idx}, {acc, _last} ->
cov = %{coverage | receiver_height_m: height_m}
progress = 15 + round((idx + 1) / total * 75)
with {:ok, output} <- compute_pixels(cov, antenna, lat, lon, bbox, grid),
suffix = tier_suffix(height_m),
{:ok, paths} <- Raster.write(coverage, output.pixels, output.dims, suffix) do
_ = update_progress(coverage, "computing", progress)
{:cont, {Map.put(acc, height_m, paths), nil}}
else
{:error, reason} -> {:halt, {acc, reason}}
end
end)
case {error, Map.get(results, @default_tier_m)} do
{nil, %{} = default} ->
{:ok, %{tiers: results, default: default}}
{nil, nil} ->
{:error, :default_tier_missing}
{reason, _} ->
{:error, reason}
end
end
defp tier_suffix(height_m) do
cm = round(height_m * 100)
"_h#{cm}cm"
end
defp resolve_antenna(%Coverage{antenna_slug: slug}) do
case Antenna.get(slug) do
nil -> {:error, {:unknown_antenna, slug}}
@ -277,7 +320,14 @@ defmodule Towerops.Workers.CoverageWorker do
defp status_atom("ready"), do: :ready
defp status_atom("failed"), do: :failed
defp finalize(coverage, %{tif: tif, png: png, bbox: {min_lat, min_lon, max_lat, max_lon}}) do
defp finalize(coverage, %{tiers: tiers, default: default}) do
%{tif: tif, png: png, bbox: {min_lat, min_lon, max_lat, max_lon}} = default
height_tiers =
Map.new(tiers, fn {height_m, %{png: tier_png}} ->
{Float.to_string(height_m * 1.0), tier_png}
end)
{:ok, updated} =
Coverages.mark_status(coverage, "ready", %{
progress_pct: 100,
@ -285,6 +335,7 @@ defmodule Towerops.Workers.CoverageWorker do
computed_at: DateTime.truncate(DateTime.utc_now(), :second),
raster_path: tif,
png_path: png,
height_tiers: height_tiers,
bbox_min_lat: min_lat,
bbox_min_lon: min_lon,
bbox_max_lat: max_lat,

View file

@ -132,7 +132,10 @@ defmodule ToweropsWeb.CoverageLive.Map do
png: c.png_path,
bbox: [c.bbox_min_lat, c.bbox_min_lon, c.bbox_max_lat, c.bbox_max_lon],
tx: %{lat: tx_lat, lon: tx_lon, azimuth: c.azimuth_deg},
site: c.site && c.site.name
site: c.site && c.site.name,
# Map of "height_m" string → relative PNG path. Empty for
# coverages computed before the multi-height pipeline.
height_tiers: c.height_tiers || %{}
}
end)
end

View file

@ -50,6 +50,29 @@
>
</div>
<%!-- SM install-height slider (vertical, left side) — cnHeat-style --%>
<div
class="pointer-events-none fixed top-32 left-1/2 -translate-x-[640px] z-[1100]"
id="coverage-height-control"
>
<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-3 flex flex-col items-center gap-2 w-20">
<span class="text-[10px] font-medium text-gray-700 dark:text-gray-300 text-center leading-tight">
{t("Height above clutter")}
</span>
<input
id="coverage-height-slider"
type="range"
min="0"
max="5"
step="1"
value="1"
class="cursor-pointer"
style="writing-mode: vertical-lr; direction: rtl; height: 200px; width: 24px;"
/>
<span id="coverage-height-label" class="text-xs font-mono text-center">10 ft</span>
</div>
</div>
<%!-- Tower toggle list (top-right, overlay above map) --%>
<div
class="pointer-events-none fixed top-32 right-6 z-[1100] w-72 max-h-[60vh] overflow-y-auto"

View file

@ -0,0 +1,12 @@
defmodule Towerops.Repo.Migrations.AddHeightTiersToCoverages do
use Ecto.Migration
def change do
alter table(:coverages) do
# Map of receiver-height-in-metres → relative PNG path written
# by the worker. Empty for coverages that haven't been recomputed
# since the multi-height pipeline shipped.
add :height_tiers, :map, default: %{}, null: false
end
end
end