towerops/lib/towerops_web/live/coverage_live/map.ex
Graham McIntire 0e2cc6b7ce chore(coverage,types): clean dialyzer + new tests + ETH canopy provider
Dialyzer:
* Add Lidar.Tile @type, narrow several @specs to match success-typing,
  prefix discarded function returns with `_ =`, fix the broken
  File.stream! call in the ms-buildings worker (it was causing dialyzer
  to mark every private fun unreachable), broaden Antenna.spec key
  types from `float()` to `number()` to match catalog int literals.
* Add :geo, :geo_postgis, :db_connection to plt_add_apps so the dep
  PLT actually contains the modules we use.
* Result: 0 unsuppressed dialyzer warnings (was 50+ in project code).

Tree canopy:
* Replace the dead landfire.gov URL with the ETH Zurich Global
  Canopy Height 10 m product. ETH publishes proper COGs with
  overviews on libdrive.ethz.ch — confirmed working with
  /vsicurl/ byte-range reads. The module now picks the 3°×3° tile
  containing the bbox centroid, with both a `:metres` decoder
  (ETH Byte raster, NoData=255) and a `:landfire_evh` decoder for
  operators self-hosting LANDFIRE EVH COGs.
* Re-enable canopy by default now that there's a working source.

Tests (+105 tests, 0 failures):
* CoverageLive Index/Show/Form/Map — empty/loaded/auth states,
  LiveView event handlers, formatters.
* Coverages.Building changeset + helpers.
* Coverages.TreeCanopy provider tile-URL builder and decoders.
* Workers.MsBuildingsImportWorker (full import flow incl. blank
  lines, MultiPolygon, idempotency, hash fallback) — also fixed
  the partial-index ON CONFLICT bug that prevented imports from
  ever working: the unique index has WHERE ms_footprint_id IS
  NOT NULL, so the worker now uses an :unsafe_fragment conflict
  target that includes the predicate.
* GraphQLDocsController smoke test (was 0%).

Coverage rose from 73.19% → 74% with the major project modules I
touched all moved from 0% / <50% to >75%.
2026-05-07 09:11:17 -05:00

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