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%.
185 lines
5.4 KiB
Elixir
185 lines
5.4 KiB
Elixir
defmodule ToweropsWeb.CoverageLive.Show do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Coverages
|
|
alias Towerops.Coverages.Antenna
|
|
alias Towerops.Coverages.Coverage
|
|
alias ToweropsWeb.CoverageLive.Map, as: CoverageMapView
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
coverage = organization.id |> Coverages.get_coverage!(id) |> Towerops.Repo.preload([:device, :site])
|
|
antenna = Antenna.get(coverage.antenna_slug)
|
|
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
|
|
|
|
_ = if connected?(socket), do: Coverages.subscribe(coverage.id)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, coverage.name)
|
|
|> assign(:organization, organization)
|
|
|> assign(:coverage, coverage)
|
|
|> assign(:antenna, antenna)
|
|
|> assign(:eirp_dbm, eirp)
|
|
|> assign(:probe, nil)
|
|
|> assign(:probe_units, "ft")
|
|
|> assign(:show_params, true)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(_params, _url, socket), do: {:noreply, socket}
|
|
|
|
@impl true
|
|
def handle_event("recompute", _params, socket) do
|
|
case Coverages.queue_compute(socket.assigns.coverage) do
|
|
{:ok, coverage} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:coverage, coverage)
|
|
|> put_flash(:info, t("Coverage queued for recompute"))}
|
|
|
|
{:error, :compute_in_progress} ->
|
|
{:noreply, put_flash(socket, :error, t("Coverage is already being computed"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to queue coverage"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete", _params, socket) do
|
|
case Coverages.delete_coverage(socket.assigns.coverage) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Coverage deleted"))
|
|
|> push_navigate(to: ~p"/coverage")}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Unable to delete coverage"))}
|
|
end
|
|
end
|
|
|
|
# Probe (Distance Tool) — clicking the map fires this.
|
|
@impl true
|
|
def handle_event("probe_point", %{"lat" => lat, "lon" => lon}, socket) do
|
|
lat = to_float(lat)
|
|
lon = to_float(lon)
|
|
|
|
# Reuse the org-wide query but only keep this coverage's row.
|
|
rows =
|
|
socket.assigns.organization.id
|
|
|> Coverages.query_point(lat, lon)
|
|
|> Enum.filter(&(&1.coverage.id == socket.assigns.coverage.id))
|
|
|
|
{:noreply, assign(socket, :probe, %{lat: lat, lon: lon, rows: rows})}
|
|
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_event("toggle_params", _params, socket) do
|
|
{:noreply, update(socket, :show_params, &(!&1))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:coverage_status, _status, _extra}, socket) do
|
|
coverage =
|
|
socket.assigns.organization.id
|
|
|> Coverages.get_coverage!(socket.assigns.coverage.id)
|
|
|> Towerops.Repo.preload([:device, :site])
|
|
|
|
eirp =
|
|
Coverage.eirp_dbm(
|
|
coverage,
|
|
(socket.assigns.antenna && socket.assigns.antenna.gain_dbi) || 0.0
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:coverage, coverage)
|
|
|> assign(:eirp_dbm, eirp)
|
|
|> push_event("coverage:list-changed", %{
|
|
coverages: CoverageMapView.coverages_payload([coverage]),
|
|
enabled: [coverage.id]
|
|
})}
|
|
end
|
|
|
|
# -- helpers (template) --
|
|
|
|
defdelegate coverages_payload(coverages), to: CoverageMapView
|
|
defdelegate fmt_distance(m, units), to: CoverageMapView
|
|
defdelegate fmt_rssi(v), to: CoverageMapView
|
|
|
|
@doc false
|
|
def status_badge_class("draft"), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
|
|
|
def status_badge_class("queued"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
|
|
|
def status_badge_class("computing"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
|
|
|
|
def status_badge_class("ready"), do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
|
|
|
|
def status_badge_class("failed"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
|
|
|
|
def status_badge_class(_), do: "bg-gray-100 text-gray-700"
|
|
|
|
@doc false
|
|
def coverage_lat(coverage) do
|
|
case Coverage.location(coverage) do
|
|
{lat, _lon} -> lat
|
|
nil -> nil
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def coverage_lon(coverage) do
|
|
case Coverage.location(coverage) do
|
|
{_lat, lon} -> lon
|
|
nil -> nil
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def format_ft(nil), do: "—"
|
|
def format_ft(m) when is_number(m), do: "#{round(m / 0.3048)} ft"
|
|
|
|
@doc false
|
|
def format_mi(nil), do: "—"
|
|
def format_mi(m) when is_number(m), do: "#{Float.round(m / 1609.344, 2)} mi"
|
|
|
|
@doc false
|
|
def format_ghz(nil), do: "—"
|
|
def format_ghz(mhz) when is_number(mhz), do: "#{Float.round(mhz / 1000.0, 3)} GHz"
|
|
|
|
attr :label, :string, required: true
|
|
attr :value, :string, required: true
|
|
|
|
def param_row(assigns) do
|
|
~H"""
|
|
<div class="flex justify-between gap-3">
|
|
<dt class="text-gray-600 dark:text-gray-400">{@label}</dt>
|
|
<dd class="text-gray-900 dark:text-gray-100 font-medium text-right">{@value}</dd>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
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
|