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:
parent
5d1adcaacc
commit
434f5782d0
7 changed files with 204 additions and 24 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue