From 434f5782d0f4c2e720a5549aea18cfba0d707972 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 6 May 2026 16:38:07 -0500 Subject: [PATCH] feat(coverage): multi-SM-height compute + cnHeat-style height slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/js/hooks/coverage_hooks.ts | 79 ++++++++++++++++++- lib/towerops/coverages/coverage.ex | 9 ++- lib/towerops/coverages/raster.ex | 37 +++++---- lib/towerops/workers/coverage_worker.ex | 63 +++++++++++++-- lib/towerops_web/live/coverage_live/map.ex | 5 +- .../live/coverage_live/map.html.heex | 23 ++++++ ...06220000_add_height_tiers_to_coverages.exs | 12 +++ 7 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 priv/repo/migrations/20260506220000_add_height_tiers_to_coverages.exs diff --git a/assets/js/hooks/coverage_hooks.ts b/assets/js/hooks/coverage_hooks.ts index 6e331478..93b41995 100644 --- a/assets/js/hooks/coverage_hooks.ts +++ b/assets/js/hooks/coverage_hooks.ts @@ -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 } +// 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 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 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") diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex index 9f5269ae..e55034e5 100644 --- a/lib/towerops/coverages/coverage.ex +++ b/lib/towerops/coverages/coverage.ex @@ -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) diff --git a/lib/towerops/coverages/raster.ex b/lib/towerops/coverages/raster.ex index 85a8d57e..2b6bcbd5 100644 --- a/lib/towerops/coverages/raster.ex +++ b/lib/towerops/coverages/raster.ex @@ -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 diff --git a/lib/towerops/workers/coverage_worker.ex b/lib/towerops/workers/coverage_worker.ex index c9ba4ef3..ac424dd5 100644 --- a/lib/towerops/workers/coverage_worker.ex +++ b/lib/towerops/workers/coverage_worker.ex @@ -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, diff --git a/lib/towerops_web/live/coverage_live/map.ex b/lib/towerops_web/live/coverage_live/map.ex index 1b4b7133..33e5d4c0 100644 --- a/lib/towerops_web/live/coverage_live/map.ex +++ b/lib/towerops_web/live/coverage_live/map.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/map.html.heex b/lib/towerops_web/live/coverage_live/map.html.heex index 43d65e97..4ba78b3e 100644 --- a/lib/towerops_web/live/coverage_live/map.html.heex +++ b/lib/towerops_web/live/coverage_live/map.html.heex @@ -50,6 +50,29 @@ > + <%!-- SM install-height slider (vertical, left side) — cnHeat-style --%> +
+
+ + {t("Height above clutter")} + + + 10 ft +
+
+ <%!-- Tower toggle list (top-right, overlay above map) --%>