feat(coverage): cnHeat-style unified multi-tower map at /coverage

- /coverage now renders a full-bleed Leaflet map of every ready
  coverage in the org as one composite heatmap; the prior table view
  moves to /coverage/list.
- Top-right tower toggle list with Show All / Hide All. Disabled
  towers grey out and their overlay/marker drop from the map.
- Bottom-center opacity slider controls all overlays in unison; the
  cnHeat dBm legend renders next to it.
- Top-left base-layer toggle (OSM streets / Esri satellite) and a
  Nominatim address search bar that drops a marker.
- Click anywhere on the map → Distance Tool side panel with each
  tower sorted by distance and the predicted RSSI at that point
  (read from each coverage's GeoTIFF via gdallocationinfo). ft/m
  unit toggle, NA for out-of-bbox, color-coded by signal quality.
- Backend additions: Coverages.list_ready_for_organization/1,
  Coverages.query_point/3, Raster.query_rssi/3 (gdallocationinfo
  wrapper with bbox guard, NoData sentinel handling).
- New JS hook MultiCoverageMap manages the layer set, toolbar,
  search, opacity, click-to-probe, and live updates via PubSub
  (coverage:enabled-changed and coverage:list-changed).
This commit is contained in:
Graham McIntire 2026-05-06 16:00:43 -05:00
parent a0d72eee5c
commit 39a5892d09
6 changed files with 834 additions and 2 deletions

View file

@ -2047,6 +2047,288 @@ const CoverageMap = {
},
}
// MultiCoverageMap renders every coverage in the org as a layered set
// of L.imageOverlays on a single map. The server pushes enabled-id
// changes via the "coverage:enabled-changed" event and full reloads
// via "coverage:list-changed". User clicks on the map fire a
// "probe_point" LiveView event with {lat, lon}.
type CoveragePayload = {
id: string
name: string
png: string
bbox: [number, number, number, number] // [min_lat, min_lon, max_lat, max_lon]
tx: { lat: number | null, lon: number | null, azimuth: number | null }
site: string | null
}
const MultiCoverageMap = {
map: null as any,
baseStreets: null as any,
baseSatellite: null as any,
layerCtl: null as any,
searchControl: null as any,
overlays: new Map<string, any>(),
markers: new Map<string, any>(),
enabled: new Set<string>(),
coverages: [] as CoveragePayload[],
opacityListener: null as ((e: Event) => void) | null,
searchListener: null as ((e: KeyboardEvent) => void) | null,
probeMarker: null as any,
searchMarker: null as any,
mounted(this: any) {
ensureLeaflet().then(() => this.init()).catch((e) => {
console.error('MultiCoverageMap: Leaflet failed to load', e)
})
},
destroyed(this: any) {
if (this.opacityListener) {
const slider = document.getElementById('coverage-opacity-slider')
if (slider) slider.removeEventListener('input', this.opacityListener)
this.opacityListener = null
}
if (this.searchListener) {
const input = document.getElementById('coverage-map-search')
if (input) input.removeEventListener('keydown', this.searchListener as any)
this.searchListener = null
}
if (this.map) {
this.map.remove()
this.map = null
this.overlays.clear()
this.markers.clear()
this.enabled.clear()
}
},
init(this: any) {
this.coverages = JSON.parse(this.el.dataset.coverages || '[]')
const enabledList: string[] = JSON.parse(this.el.dataset.enabled || '[]')
this.enabled = new Set(enabledList)
// Determine an initial view: average of TX positions, or first bbox.
const init = this.computeInitialView()
// Inject the floating top-left toolbar (base toggle + search) outside
// Phoenix-managed DOM so phx-update="ignore" plays well.
this.injectToolbar()
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true, attributionControl: true })
.setView([init.lat, init.lon], init.zoom)
this.baseStreets = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <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 &copy; Esri',
maxZoom: 19,
}
)
this.baseSatellite.addTo(this.map)
this.coverages.forEach((c) => this.addCoverageLayer(c))
this.applyEnabled()
this.map.on('click', (e: any) => {
this.placeProbeMarker(e.latlng.lat, e.latlng.lng)
this.pushEvent('probe_point', { lat: e.latlng.lat, lon: e.latlng.lng })
})
this.bindOpacitySlider()
this.bindBaseToggle()
this.bindSearch()
this.handleEvent('coverage:enabled-changed', ({ enabled }: { enabled: string[] }) => {
this.enabled = new Set(enabled)
this.applyEnabled()
})
this.handleEvent('coverage:list-changed', (payload: { coverages: CoveragePayload[], enabled: string[] }) => {
// Refresh the layer set entirely.
this.overlays.forEach((o) => this.map.removeLayer(o))
this.markers.forEach((m) => this.map.removeLayer(m))
this.overlays.clear()
this.markers.clear()
this.coverages = payload.coverages
this.enabled = new Set(payload.enabled)
this.coverages.forEach((c) => this.addCoverageLayer(c))
this.applyEnabled()
})
},
computeInitialView(this: any): { lat: number, lon: number, zoom: number } {
if (this.coverages.length === 0) return { lat: 31.0, lon: -97.0, zoom: 6 }
const lats: number[] = []
const lons: number[] = []
this.coverages.forEach((c: CoveragePayload) => {
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: any) {
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)
this.cleanupToolbar = () => {
const el = document.getElementById('coverage-map-toolbar')
if (el) el.remove()
}
},
addCoverageLayer(this: any, c: CoveragePayload) {
const [minLat, minLon, maxLat, maxLon] = c.bbox
const overlay = L.imageOverlay(c.png, [[minLat, minLon], [maxLat, maxLon]], {
opacity: this.currentOpacity(),
interactive: false,
})
this.overlays.set(c.id, overlay)
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: any) {
this.coverages.forEach((c: CoveragePayload) => {
const overlay = this.overlays.get(c.id)
const marker = this.markers.get(c.id)
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: any) {
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: any) => o.setOpacity(v))
const label = document.getElementById('coverage-opacity-label')
if (label) label.textContent = `${Math.round(v * 100)}%`
}
slider.addEventListener('input', this.opacityListener)
},
currentOpacity(this: any): number {
const slider = document.getElementById('coverage-opacity-slider') as HTMLInputElement | null
if (!slider) return 0.7
return parseInt(slider.value, 10) / 100
},
bindBaseToggle(this: any) {
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: any) {
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()
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 as any)
},
placeProbeMarker(this: any, 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)
},
cleanupToolbar: null as null | (() => void),
}
const csrfToken = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
if (!csrfToken) {
throw new Error('CSRF token meta tag not found')
@ -2673,7 +2955,7 @@ const WeathermapViewer = {
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 5000,
params: { _csrf_token: csrfToken, timezone: userTimezone },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, CoverageLocationPicker, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, MultiCoverageMap, CoverageLocationPicker, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
})
// Show progress bar on live navigation and form submits

View file

@ -22,6 +22,7 @@ defmodule Towerops.Coverages do
import Ecto.Query
alias Towerops.Coverages.Coverage
alias Towerops.Coverages.Raster
alias Towerops.Repo
alias Towerops.Sites.Site
alias Towerops.Workers.CoverageWorker
@ -85,6 +86,70 @@ defmodule Towerops.Coverages do
|> Repo.all()
end
@doc """
Lists ready coverages those with a computed PNG/GeoTIFF and bbox
for the unified map view. Site is preloaded for the toggle list.
"""
@spec list_ready_for_organization(Ecto.UUID.t()) :: [Coverage.t()]
def list_ready_for_organization(organization_id) do
Coverage
|> where(organization_id: ^organization_id)
|> where(status: "ready")
|> where([c], not is_nil(c.png_path) and not is_nil(c.bbox_min_lat))
|> order_by(asc: :name)
|> preload(:site)
|> Repo.all()
end
@doc """
For each ready coverage in the organization, returns the
great-circle distance (m) and predicted RSSI (dBm) at `(lat, lon)`.
Coverages whose bbox does not contain the point or that have no
raster on disk return `:no_coverage` for the rssi.
"""
@spec query_point(Ecto.UUID.t(), float(), float()) :: [
%{
coverage: Coverage.t(),
distance_m: float(),
rssi: float() | :no_coverage | nil
}
]
def query_point(organization_id, lat, lon) when is_number(lat) and is_number(lon) do
organization_id
|> list_ready_for_organization()
|> Enum.map(&summarize_point(&1, lat, lon))
end
defp summarize_point(coverage, lat, lon) do
{tx_lat, tx_lon} = Coverage.location(coverage) || {coverage.bbox_min_lat, coverage.bbox_min_lon}
distance = haversine_m({tx_lat, tx_lon}, {lat, lon})
rssi =
case Raster.query_rssi(coverage, lat, lon) do
{:ok, :no_coverage} -> :no_coverage
{:ok, dbm} when is_number(dbm) -> dbm
{:error, _} -> nil
end
%{coverage: coverage, distance_m: distance, rssi: rssi}
end
defp haversine_m({lat1, lon1}, {lat2, lon2}) do
r = 6_371_000.0
p1 = lat1 * :math.pi() / 180.0
p2 = lat2 * :math.pi() / 180.0
dp = (lat2 - lat1) * :math.pi() / 180.0
dl = (lon2 - lon1) * :math.pi() / 180.0
a =
:math.sin(dp / 2) * :math.sin(dp / 2) +
:math.cos(p1) * :math.cos(p2) * :math.sin(dl / 2) * :math.sin(dl / 2)
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
r * c
end
@doc "Fetches a coverage scoped to an organization. Raises if not found."
@spec get_coverage!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t()
def get_coverage!(organization_id, id) do

View file

@ -90,6 +90,85 @@ defmodule Towerops.Coverages.Raster do
:ok
end
@doc """
Reads the RSSI value (dBm) at a single WGS84 point from the
coverage's GeoTIFF via `gdallocationinfo`.
Returns:
* `{:ok, dbm}` pixel had a valid value
* `{:ok, :no_coverage}` pixel was the NoData sentinel
* `{:error, :outside_bbox}` point is outside the raster's extent
* `{:error, :not_ready}` coverage hasn't been computed yet
* `{:error, term}` gdal failure
"""
@spec query_rssi(Coverage.t(), float(), float()) ::
{:ok, float() | :no_coverage} | {:error, term()}
def query_rssi(%Coverage{status: status, raster_path: nil}, _lat, _lon) when status != "ready", do: {:error, :not_ready}
def query_rssi(%Coverage{raster_path: nil}, _lat, _lon), do: {:error, :not_ready}
def query_rssi(%Coverage{} = coverage, lat, lon) when is_number(lat) and is_number(lon) do
if inside_bbox?(coverage, lat, lon) do
tif = absolute_raster_path(coverage)
if File.exists?(tif) do
run_gdallocationinfo(tif, lon, lat)
else
{:error, :raster_missing}
end
else
{:error, :outside_bbox}
end
end
defp inside_bbox?(
%Coverage{bbox_min_lat: min_lat, bbox_max_lat: max_lat, bbox_min_lon: min_lon, bbox_max_lon: max_lon},
lat,
lon
)
when is_number(min_lat) and is_number(max_lat) and is_number(min_lon) and is_number(max_lon) do
lat >= min_lat and lat <= max_lat and lon >= min_lon and lon <= max_lon
end
defp inside_bbox?(_coverage, _lat, _lon), do: false
defp absolute_raster_path(%Coverage{raster_path: rel}) do
Path.join(static_dir(), Path.relative_to(rel, "/"))
end
# gdallocationinfo with -wgs84 -valonly prints just the pixel value or
# a blank line if outside, plus an error code line.
defp run_gdallocationinfo(tif, lon, lat) do
args = ["-valonly", "-wgs84", tif, Float.to_string(lon * 1.0), Float.to_string(lat * 1.0)]
case System.cmd("gdallocationinfo", args, stderr_to_stdout: true) do
{output, 0} ->
parse_gdal_value(output)
{output, code} ->
{:error, {:gdallocationinfo_failed, code, String.slice(output, 0, 200)}}
end
end
defp parse_gdal_value(output) do
trimmed = output |> String.trim() |> String.split("\n") |> List.first() |> to_string()
cond do
trimmed == "" ->
{:error, :outside_bbox}
String.contains?(trimmed, "outside") ->
{:error, :outside_bbox}
true ->
case Float.parse(trimmed) do
{v, _} when v >= @nan_sentinel * 0.99 -> {:ok, :no_coverage}
{v, _} -> {:ok, v}
:error -> {:error, {:bad_gdal_output, trimmed}}
end
end
end
defp output_dir(%Coverage{organization_id: org_id, id: id}) do
Path.join([static_dir(), "coverage", to_string(org_id), to_string(id)])
end

View file

@ -0,0 +1,158 @@
defmodule ToweropsWeb.CoverageLive.Map do
@moduledoc """
cnHeat-style unified coverage map.
Shows every ready coverage in the organisation as a single composite
heatmap on a Leaflet map, with:
* a per-tower toggle list (top-right) including Show All / Hide All
* an opacity slider (bottom-centre)
* a base-map toggle (Streets / Satellite, top-left)
* an address search bar (Nominatim, top-left)
* a Distance Tool (click the map side panel with each tower's
distance from the click and predicted RSSI at that location)
All ready coverages share the same colour palette so the composite
read like a single map instead of N independent overlays.
"""
use ToweropsWeb, :live_view
alias Towerops.Coverages
alias Towerops.Coverages.Coverage
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
if connected?(socket), do: Coverages.subscribe_organization(organization.id)
coverages = Coverages.list_ready_for_organization(organization.id)
{:ok,
socket
|> assign(:page_title, t("Coverage Map"))
|> assign(:organization, organization)
|> assign(:coverages, coverages)
|> assign(:enabled_ids, Enum.map(coverages, & &1.id))
|> assign(:probe, nil)
|> assign(:probe_units, "ft")}
end
@impl true
def handle_event("toggle_coverage", %{"id" => id}, socket) do
enabled =
if id in socket.assigns.enabled_ids,
do: List.delete(socket.assigns.enabled_ids, id),
else: [id | socket.assigns.enabled_ids]
{:noreply,
socket
|> assign(:enabled_ids, enabled)
|> push_event("coverage:enabled-changed", %{enabled: enabled})}
end
@impl true
def handle_event("show_all", _params, socket) do
enabled = Enum.map(socket.assigns.coverages, & &1.id)
{:noreply,
socket
|> assign(:enabled_ids, enabled)
|> push_event("coverage:enabled-changed", %{enabled: enabled})}
end
@impl true
def handle_event("hide_all", _params, socket) do
{:noreply,
socket
|> assign(:enabled_ids, [])
|> push_event("coverage:enabled-changed", %{enabled: []})}
end
@impl true
def handle_event("probe_point", %{"lat" => lat, "lon" => lon}, socket) do
lat = to_float(lat)
lon = to_float(lon)
rows =
socket.assigns.organization.id
|> Coverages.query_point(lat, lon)
|> Enum.sort_by(& &1.distance_m)
probe = %{lat: lat, lon: lon, rows: rows}
{:noreply, assign(socket, :probe, probe)}
end
@impl true
def handle_event("close_probe", _params, socket) do
{:noreply, assign(socket, :probe, nil)}
end
@impl true
def handle_event("set_probe_units", %{"units" => units}, socket) when units in ["ft", "m"] do
{:noreply, assign(socket, :probe_units, units)}
end
@impl true
def handle_info({:coverage_status, _status, _arg}, socket) do
coverages = Coverages.list_ready_for_organization(socket.assigns.organization.id)
enabled =
socket.assigns.enabled_ids
|> MapSet.new()
|> MapSet.intersection(MapSet.new(Enum.map(coverages, & &1.id)))
|> MapSet.to_list()
new_ids =
coverages
|> Enum.map(& &1.id)
|> Enum.reject(&(&1 in socket.assigns.enabled_ids))
enabled = enabled ++ new_ids
{:noreply,
socket
|> assign(:coverages, coverages)
|> assign(:enabled_ids, enabled)
|> push_event("coverage:list-changed", %{
coverages: coverages_payload(coverages),
enabled: enabled
})}
end
@doc false
def coverages_payload(coverages) do
Enum.map(coverages, fn c ->
{tx_lat, tx_lon} = Coverage.location(c) || {nil, nil}
%{
id: c.id,
name: c.name,
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
}
end)
end
@doc false
def fmt_distance(m, "ft") when is_number(m), do: "#{:erlang.float_to_binary(m * 3.28084 / 5280.0, decimals: 2)} mi"
def fmt_distance(m, "m") when is_number(m), do: "#{:erlang.float_to_binary(m / 1000.0, decimals: 2)} km"
def fmt_distance(_, _), do: ""
@doc false
def fmt_rssi(:no_coverage), do: "N/A"
def fmt_rssi(nil), do: ""
def fmt_rssi(v) when is_number(v), do: "#{:erlang.float_to_binary(v * 1.0, decimals: 1)} dBm"
defp to_float(v) when is_number(v), do: v * 1.0
defp to_float(v) when is_binary(v) do
case Float.parse(v) do
{n, _} -> n
:error -> 0.0
end
end
end

View file

@ -0,0 +1,247 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.header>
{@page_title}
<:subtitle>
{t("All ready coverages overlaid. Click any point to query each tower's predicted RSSI.")}
</:subtitle>
<:actions>
<.link navigate={~p"/coverage/list"} class="btn btn-outline btn-sm gap-1.5">
<.icon name="hero-list-bullet" class="h-4 w-4" /> {t("List view")}
</.link>
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")}
</.button>
</:actions>
</.header>
<%= if @coverages == [] do %>
<div class="mt-6 flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No ready coverages")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t(
"Coverage maps appear here once the background compute finishes. Create or wait for a coverage to finish."
)}
</p>
<div class="card-actions mt-2">
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
</.button>
</div>
</div>
</div>
</div>
<% else %>
<div
id="coverage-multi-map"
phx-hook="MultiCoverageMap"
phx-update="ignore"
class="relative mt-6 h-[78vh] w-full rounded-lg border border-gray-200 dark:border-white/10 bg-gray-100 dark:bg-gray-800 overflow-hidden"
data-coverages={Jason.encode!(coverages_payload(@coverages))}
data-enabled={Jason.encode!(@enabled_ids)}
>
</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"
id="coverage-tower-list"
>
<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">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{t("Towers")}</h3>
<div class="flex gap-1.5">
<button
type="button"
phx-click="show_all"
class="text-xs px-1.5 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
>
{t("Show All")}
</button>
<button
type="button"
phx-click="hide_all"
class="text-xs px-1.5 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
>
{t("Hide All")}
</button>
</div>
</div>
<ul class="py-1">
<%= for c <- @coverages do %>
<li>
<button
type="button"
phx-click="toggle_coverage"
phx-value-id={c.id}
class={[
"w-full text-left flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800",
if(c.id in @enabled_ids,
do: "text-gray-900 dark:text-white font-medium",
else: "text-gray-400 dark:text-gray-500"
)
]}
>
<span class={[
"inline-block h-2.5 w-2.5 rounded-full",
if(c.id in @enabled_ids,
do: "bg-green-500",
else: "bg-gray-300 dark:bg-gray-600"
)
]}>
</span>
<span class="text-sm truncate flex-1">{c.name}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate">
{c.site && c.site.name}
</span>
</button>
</li>
<% end %>
</ul>
</div>
</div>
<%!-- Opacity slider + legend (bottom-center, overlay above map) --%>
<div class="pointer-events-none fixed bottom-6 left-1/2 -translate-x-1/2 z-[1100] w-[640px] max-w-[90vw]">
<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 space-y-2">
<div class="flex items-center gap-3">
<label
for="coverage-opacity-slider"
class="text-xs font-medium text-gray-700 dark:text-gray-300 w-16"
>
{t("Opacity")}
</label>
<input
id="coverage-opacity-slider"
type="range"
min="0"
max="100"
value="70"
class="flex-1 cursor-pointer"
/>
<span id="coverage-opacity-label" class="text-xs font-mono w-10 text-right">70%</span>
</div>
<div class="flex items-center gap-1 text-[10px] font-mono text-gray-700 dark:text-gray-300">
<span class="w-3 h-3 rounded inline-block" style="background:rgb(0,200,0)"></span>
<span>≥-50</span>
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(200,230,0)">
</span>
<span>-65</span>
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(255,165,0)">
</span>
<span>-75</span>
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(220,60,60)">
</span>
<span>-85</span>
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(100,0,0)"></span>
<span>≤-95 dBm</span>
</div>
</div>
</div>
<%!-- Distance Tool side panel — opens when the user clicks the map --%>
<div
:if={@probe}
class="pointer-events-none fixed top-32 left-6 z-[1100] w-[420px] max-w-[90vw] max-h-[60vh] overflow-y-auto"
id="coverage-probe-panel"
>
<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">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
<div class="flex items-center gap-2 text-xs font-mono text-gray-700 dark:text-gray-300">
<.icon name="hero-map-pin" class="h-4 w-4 text-blue-500" />
<span>
{:erlang.float_to_binary(@probe.lat * 1.0, decimals: 5)}, {:erlang.float_to_binary(
@probe.lon * 1.0,
decimals: 5
)}
</span>
</div>
<button
type="button"
phx-click="close_probe"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400"
>
<.icon name="hero-x-mark" class="h-4 w-4" />
</button>
</div>
<div class="px-3 py-2 border-b border-gray-200 dark:border-white/10 flex items-center gap-3">
<span class="text-xs text-gray-700 dark:text-gray-300">{t("Units:")}</span>
<label class="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name="probe-units"
checked={@probe_units == "ft"}
phx-click="set_probe_units"
phx-value-units="ft"
/> ft
</label>
<label class="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name="probe-units"
checked={@probe_units == "m"}
phx-click="set_probe_units"
phx-value-units="m"
/> m
</label>
</div>
<table class="min-w-full text-xs">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-3 py-1.5 text-left font-medium text-gray-700 dark:text-gray-300">
{t("Tower")}
</th>
<th class="px-3 py-1.5 text-right font-medium text-gray-700 dark:text-gray-300">
{t("Distance")}
</th>
<th class="px-3 py-1.5 text-right font-medium text-gray-700 dark:text-gray-300">
RSSI
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for row <- @probe.rows do %>
<tr>
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-gray-100 truncate">
{row.coverage.name}
</td>
<td class="px-3 py-1.5 text-right font-mono text-gray-700 dark:text-gray-300">
{fmt_distance(row.distance_m, @probe_units)}
</td>
<td class={[
"px-3 py-1.5 text-right font-mono",
cond do
row.rssi == :no_coverage ->
"text-gray-400"
is_number(row.rssi) and row.rssi >= -75 ->
"text-green-600 dark:text-green-400 font-bold"
is_number(row.rssi) and row.rssi >= -85 ->
"text-yellow-600 dark:text-yellow-400"
is_number(row.rssi) ->
"text-red-600 dark:text-red-400"
true ->
"text-gray-400"
end
]}>
{fmt_rssi(row.rssi)}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -512,7 +512,8 @@ defmodule ToweropsWeb.Router do
live "/sites-map", MapLive.Index, :index
# RF coverage prediction
live "/coverage", CoverageLive.Index, :index
live "/coverage", CoverageLive.Map, :index
live "/coverage/list", CoverageLive.Index, :index
live "/coverage/new", CoverageLive.Form, :new
live "/coverage/:id", CoverageLive.Show, :show
live "/coverage/:id/edit", CoverageLive.Form, :edit