Closes the cnHeat-parity gap on modes and clutter:
Backend
- Towerops.Coverages.Buildings.for_bbox/1 — PostGIS ST_Intersects
query that returns each building polygon as a compact
%{height_m, coords} map ready for in-memory point-in-polygon
testing, so the per-pixel hot loop never round-trips to
Postgres.
- Towerops.Coverages.Profile.sample/5 — accepts an optional
:clutter list and adds the building rooftop height to terrain
whenever a sample point falls inside a polygon. Pure ray-cast
PIP for portability.
- Towerops.Workers.CoverageWorker — fetches buildings once per
job and computes BOTH LOS and NLOS variants per SM-height
tier (12 PNGs total). LOS passes clutter: [] (height-above-
clutter view), NLOS passes the prefetched list (height-above-
ground view with rooftop diffraction). Pixel-loop signature
bundled into a ctx map to keep the function arity within
Credo's max-8 limit.
- Towerops.Workers.MsBuildingsImportWorker — streams Microsoft
GlobalMLBuildingFootprints GeoJSONSeq exports line-by-line,
upserts into coverage_buildings in 500-row batches via
insert_all + ON CONFLICT (source, ms_footprint_id). Handles
Polygon and MultiPolygon geometries; stable-hashes the bbox
when an explicit feature id is missing so re-imports are
idempotent. Public import_file/2 lets ops drive a state at
a time from IEx.
- Schema: nlos_tiers JSONB column on coverages, status_changeset
whitelist, payload exposed via list_ready_for_organization.
Frontend
- LOS / NLOS toggle pills above the height slider (left panel).
Selecting NLOS swaps each L.imageOverlay's URL via setUrl to
the matching nlos_tiers PNG, drops the cached pixel data, and
re-decodes for the RSSI filter; falls back to the other
mode's tiers when one is empty so older coverages still
render. coverage_hooks chunk: 22 KB → 25 KB.
165 lines
4.9 KiB
Elixir
165 lines
4.9 KiB
Elixir
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,
|
|
# cnHeat-style mode/height tier maps. `height_tiers` is the
|
|
# LOS view (height-above-clutter), `nlos_tiers` the NLOS view
|
|
# (height-above-ground, buildings as obstacles). Both empty
|
|
# for coverages that haven't been recomputed since the
|
|
# multi-mode pipeline shipped.
|
|
height_tiers: c.height_tiers || %{},
|
|
nlos_tiers: c.nlos_tiers || %{}
|
|
}
|
|
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
|