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]
|
bbox: [number, number, number, number]
|
||||||
tx: { lat: number | null; lon: number | null; azimuth: number | null }
|
tx: { lat: number | null; lon: number | null; azimuth: number | null }
|
||||||
site: string | 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 = {
|
type CoverageFilterState = {
|
||||||
bbox: [number, number, number, number]
|
bbox: [number, number, number, number]
|
||||||
pixels: ImageData
|
pixels: ImageData
|
||||||
|
|
@ -277,8 +282,10 @@ type MultiCoverageMapState = PhxHookContext & {
|
||||||
coverages: CoveragePayload[]
|
coverages: CoveragePayload[]
|
||||||
filters: Map<string, CoverageFilterState>
|
filters: Map<string, CoverageFilterState>
|
||||||
rssiThreshold: number
|
rssiThreshold: number
|
||||||
|
heightTierIdx: number
|
||||||
opacityListener: ((e: Event) => void) | null
|
opacityListener: ((e: Event) => void) | null
|
||||||
rssiListener: ((e: Event) => void) | null
|
rssiListener: ((e: Event) => void) | null
|
||||||
|
heightListener: ((e: Event) => void) | null
|
||||||
searchListener: ((e: KeyboardEvent) => void) | null
|
searchListener: ((e: KeyboardEvent) => void) | null
|
||||||
probeMarker: any | null
|
probeMarker: any | null
|
||||||
searchMarker: any | null
|
searchMarker: any | null
|
||||||
|
|
@ -289,9 +296,11 @@ type MultiCoverageMapState = PhxHookContext & {
|
||||||
applyEnabled: () => void
|
applyEnabled: () => void
|
||||||
bindOpacitySlider: () => void
|
bindOpacitySlider: () => void
|
||||||
bindRssiSlider: () => void
|
bindRssiSlider: () => void
|
||||||
|
bindHeightSlider: () => void
|
||||||
bindBaseToggle: () => void
|
bindBaseToggle: () => void
|
||||||
bindSearch: () => void
|
bindSearch: () => void
|
||||||
currentOpacity: () => number
|
currentOpacity: () => number
|
||||||
|
pngForCoverage: (c: CoveragePayload) => string
|
||||||
placeProbeMarker: (lat: number, lon: number) => void
|
placeProbeMarker: (lat: number, lon: number) => void
|
||||||
loadCoverageImage: (c: CoveragePayload) => Promise<void>
|
loadCoverageImage: (c: CoveragePayload) => Promise<void>
|
||||||
reapplyRssiFilter: () => void
|
reapplyRssiFilter: () => void
|
||||||
|
|
@ -335,8 +344,10 @@ export const MultiCoverageMap = {
|
||||||
coverages: [],
|
coverages: [],
|
||||||
filters: new Map(),
|
filters: new Map(),
|
||||||
rssiThreshold: -100,
|
rssiThreshold: -100,
|
||||||
|
heightTierIdx: 1,
|
||||||
opacityListener: null,
|
opacityListener: null,
|
||||||
rssiListener: null,
|
rssiListener: null,
|
||||||
|
heightListener: null,
|
||||||
searchListener: null,
|
searchListener: null,
|
||||||
probeMarker: null,
|
probeMarker: null,
|
||||||
searchMarker: null,
|
searchMarker: null,
|
||||||
|
|
@ -358,6 +369,11 @@ export const MultiCoverageMap = {
|
||||||
if (slider) slider.removeEventListener("input", this.rssiListener)
|
if (slider) slider.removeEventListener("input", this.rssiListener)
|
||||||
this.rssiListener = null
|
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) {
|
if (this.searchListener) {
|
||||||
const input = document.getElementById("coverage-map-search")
|
const input = document.getElementById("coverage-map-search")
|
||||||
if (input) input.removeEventListener("keydown", this.searchListener)
|
if (input) input.removeEventListener("keydown", this.searchListener)
|
||||||
|
|
@ -412,6 +428,7 @@ export const MultiCoverageMap = {
|
||||||
|
|
||||||
this.bindOpacitySlider()
|
this.bindOpacitySlider()
|
||||||
this.bindRssiSlider()
|
this.bindRssiSlider()
|
||||||
|
this.bindHeightSlider()
|
||||||
this.bindBaseToggle()
|
this.bindBaseToggle()
|
||||||
this.bindSearch()
|
this.bindSearch()
|
||||||
|
|
||||||
|
|
@ -473,7 +490,8 @@ export const MultiCoverageMap = {
|
||||||
|
|
||||||
addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) {
|
addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) {
|
||||||
const [minLat, minLon, maxLat, maxLon] = c.bbox
|
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(),
|
opacity: this.currentOpacity(),
|
||||||
interactive: false,
|
interactive: false,
|
||||||
})
|
})
|
||||||
|
|
@ -482,6 +500,31 @@ export const MultiCoverageMap = {
|
||||||
// Pre-decode the heatmap into ImageData so the RSSI slider can
|
// Pre-decode the heatmap into ImageData so the RSSI slider can
|
||||||
// re-mask client-side without a server round-trip.
|
// re-mask client-side without a server round-trip.
|
||||||
void this.loadCoverageImage(c)
|
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) {
|
if (c.tx.lat !== null && c.tx.lon !== null) {
|
||||||
const az = c.tx.azimuth ?? 0
|
const az = c.tx.azimuth ?? 0
|
||||||
|
|
@ -611,6 +654,38 @@ export const MultiCoverageMap = {
|
||||||
slider.addEventListener("input", this.rssiListener)
|
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
|
// Fetch the original PNG, decode it once into an ImageData, and
|
||||||
// store it so the slider can re-mask cheaply. We deliberately use
|
// store it so the slider can re-mask cheaply. We deliberately use
|
||||||
// crossOrigin="anonymous" — Phoenix serves the static assets from
|
// crossOrigin="anonymous" — Phoenix serves the static assets from
|
||||||
|
|
@ -624,7 +699,7 @@ export const MultiCoverageMap = {
|
||||||
img.onload = () => resolve(img)
|
img.onload = () => resolve(img)
|
||||||
img.onerror = (e) => reject(e)
|
img.onerror = (e) => reject(e)
|
||||||
})
|
})
|
||||||
img.src = c.png
|
img.src = this.pngForCoverage(c)
|
||||||
await loaded
|
await loaded
|
||||||
|
|
||||||
const canvas = document.createElement("canvas")
|
const canvas = document.createElement("canvas")
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,12 @@ defmodule Towerops.Coverages.Coverage do
|
||||||
field :raster_path, :string
|
field :raster_path, :string
|
||||||
field :png_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 :site, Site
|
||||||
belongs_to :organization, Organization
|
belongs_to :organization, Organization
|
||||||
belongs_to :device, Device
|
belongs_to :device, Device
|
||||||
|
|
@ -220,7 +226,8 @@ defmodule Towerops.Coverages.Coverage do
|
||||||
:bbox_min_lon,
|
:bbox_min_lon,
|
||||||
:bbox_max_lon,
|
:bbox_max_lon,
|
||||||
:raster_path,
|
:raster_path,
|
||||||
:png_path
|
:png_path,
|
||||||
|
:height_tiers
|
||||||
])
|
])
|
||||||
|> validate_inclusion(:status, @statuses)
|
|> validate_inclusion(:status, @statuses)
|
||||||
|> validate_number(:progress_pct, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|
|> 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,
|
row and `nrows` rows total. `bbox` is `{min_lat, min_lon, max_lat,
|
||||||
max_lon}` in WGS84.
|
max_lon}` in WGS84.
|
||||||
"""
|
"""
|
||||||
@spec write(Coverage.t(), [number() | :nan], %{
|
@spec write(
|
||||||
ncols: pos_integer(),
|
Coverage.t(),
|
||||||
nrows: pos_integer(),
|
[number() | :nan],
|
||||||
bbox: {number(), number(), number(), number()}
|
%{
|
||||||
}) ::
|
ncols: pos_integer(),
|
||||||
|
nrows: pos_integer(),
|
||||||
|
bbox: {number(), number(), number(), number()}
|
||||||
|
},
|
||||||
|
String.t()
|
||||||
|
) ::
|
||||||
{:ok, %{tif: String.t(), png: String.t(), bbox: tuple()}} | {:error, term()}
|
{: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
|
expected = ncols * nrows
|
||||||
|
|
||||||
if length(pixels) == expected do
|
if length(pixels) == expected do
|
||||||
do_write(coverage, pixels, ncols, nrows, bbox)
|
do_write(coverage, pixels, ncols, nrows, bbox, suffix)
|
||||||
else
|
else
|
||||||
{:error, {:pixel_count_mismatch, %{expected: expected, got: length(pixels)}}}
|
{:error, {:pixel_count_mismatch, %{expected: expected, got: length(pixels)}}}
|
||||||
end
|
end
|
||||||
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)
|
out_dir = output_dir(coverage)
|
||||||
File.mkdir_p!(out_dir)
|
File.mkdir_p!(out_dir)
|
||||||
|
|
||||||
raw_path = Path.join(out_dir, "rssi.f32")
|
base = "rssi" <> suffix
|
||||||
vrt_path = Path.join(out_dir, "rssi.vrt")
|
raw_path = Path.join(out_dir, base <> ".f32")
|
||||||
tif_path = Path.join(out_dir, "rssi.tif")
|
vrt_path = Path.join(out_dir, base <> ".vrt")
|
||||||
png_path = Path.join(out_dir, "rssi.png")
|
tif_path = Path.join(out_dir, base <> ".tif")
|
||||||
|
png_path = Path.join(out_dir, base <> ".png")
|
||||||
palette_path = Path.join(out_dir, "palette.txt")
|
palette_path = Path.join(out_dir, "palette.txt")
|
||||||
|
|
||||||
with :ok <- write_raw(raw_path, pixels),
|
with :ok <- write_raw(raw_path, pixels),
|
||||||
|
|
@ -75,8 +84,8 @@ defmodule Towerops.Coverages.Raster do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
tif: relative_static_path(coverage, "rssi.tif"),
|
tif: relative_static_path(coverage, base <> ".tif"),
|
||||||
png: relative_static_path(coverage, "rssi.png"),
|
png: relative_static_path(coverage, base <> ".png"),
|
||||||
bbox: bbox
|
bbox: bbox
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ defmodule Towerops.Workers.CoverageWorker do
|
||||||
# trade-off for WISP-scale paths.
|
# trade-off for WISP-scale paths.
|
||||||
@profile_samples 64
|
@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
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"coverage_id" => coverage_id, "organization_id" => organization_id}}) do
|
def perform(%Oban.Job{args: %{"coverage_id" => coverage_id, "organization_id" => organization_id}}) do
|
||||||
case Repo.get(Coverage, coverage_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),
|
{:ok, bbox} <- compute_bbox(coverage, lat, lon),
|
||||||
_ = update_progress(coverage, "computing", 5),
|
_ = update_progress(coverage, "computing", 5),
|
||||||
{:ok, grid} <- fetch_terrain(bbox, coverage.cell_size_m, lat),
|
{:ok, grid} <- fetch_terrain(bbox, coverage.cell_size_m, lat),
|
||||||
_ = update_progress(coverage, "computing", 30),
|
_ = update_progress(coverage, "computing", 15),
|
||||||
{:ok, output} <- compute_pixels(coverage, antenna, lat, lon, bbox, grid),
|
{:ok, tiers} <- compute_all_tiers(coverage, antenna, lat, lon, bbox, grid) do
|
||||||
_ = update_progress(coverage, "computing", 90),
|
finalize(coverage, tiers)
|
||||||
{:ok, paths} <- Raster.write(coverage, output.pixels, output.dims) do
|
|
||||||
finalize(coverage, paths)
|
|
||||||
else
|
else
|
||||||
{:error, reason} -> fail(coverage, reason)
|
{:error, reason} -> fail(coverage, reason)
|
||||||
end
|
end
|
||||||
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
|
defp resolve_antenna(%Coverage{antenna_slug: slug}) do
|
||||||
case Antenna.get(slug) do
|
case Antenna.get(slug) do
|
||||||
nil -> {:error, {:unknown_antenna, slug}}
|
nil -> {:error, {:unknown_antenna, slug}}
|
||||||
|
|
@ -277,7 +320,14 @@ defmodule Towerops.Workers.CoverageWorker do
|
||||||
defp status_atom("ready"), do: :ready
|
defp status_atom("ready"), do: :ready
|
||||||
defp status_atom("failed"), do: :failed
|
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} =
|
{:ok, updated} =
|
||||||
Coverages.mark_status(coverage, "ready", %{
|
Coverages.mark_status(coverage, "ready", %{
|
||||||
progress_pct: 100,
|
progress_pct: 100,
|
||||||
|
|
@ -285,6 +335,7 @@ defmodule Towerops.Workers.CoverageWorker do
|
||||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second),
|
computed_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||||
raster_path: tif,
|
raster_path: tif,
|
||||||
png_path: png,
|
png_path: png,
|
||||||
|
height_tiers: height_tiers,
|
||||||
bbox_min_lat: min_lat,
|
bbox_min_lat: min_lat,
|
||||||
bbox_min_lon: min_lon,
|
bbox_min_lon: min_lon,
|
||||||
bbox_max_lat: max_lat,
|
bbox_max_lat: max_lat,
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,10 @@ defmodule ToweropsWeb.CoverageLive.Map do
|
||||||
png: c.png_path,
|
png: c.png_path,
|
||||||
bbox: [c.bbox_min_lat, c.bbox_min_lon, c.bbox_max_lat, c.bbox_max_lon],
|
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},
|
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)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,29 @@
|
||||||
>
|
>
|
||||||
</div>
|
</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) --%>
|
<%!-- Tower toggle list (top-right, overlay above map) --%>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none fixed top-32 right-6 z-[1100] w-72 max-h-[60vh] overflow-y-auto"
|
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