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