diff --git a/assets/js/hooks/coverage_hooks.ts b/assets/js/hooks/coverage_hooks.ts index c692bc14..af72da2f 100644 --- a/assets/js/hooks/coverage_hooks.ts +++ b/assets/js/hooks/coverage_hooks.ts @@ -503,12 +503,42 @@ export const MultiCoverageMap = { addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) { const [minLat, minLon, maxLat, maxLon] = c.bbox const png = this.pngForCoverage(c) + if (!png) { + console.warn(`MultiCoverageMap: no PNG for coverage ${c.id}`) + return + } const overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], { opacity: this.currentOpacity(), interactive: false, }) this.overlays.set(c.id, overlay) + // Antenna marker (TX location with azimuth wedge). + if (c.tx.lat !== null && c.tx.lon !== null) { + const az = c.tx.azimuth ?? 0 + const icon = L.divIcon({ + className: "coverage-antenna-marker", + html: ` +
+
+
+
+ `, + 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) + } + // Pre-decode the heatmap into ImageData so the RSSI slider can // re-mask client-side without a server round-trip. void this.loadCoverageImage(c) @@ -540,31 +570,6 @@ export const MultiCoverageMap = { } } return best || c.png - - if (c.tx.lat !== null && c.tx.lon !== null) { - const az = c.tx.azimuth ?? 0 - const icon = L.divIcon({ - className: "coverage-antenna-marker", - html: ` -
-
-
-
- `, - 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: MultiCoverageMapState) { diff --git a/lib/towerops_web/live/coverage_live/show.ex b/lib/towerops_web/live/coverage_live/show.ex index 3ec470b6..11327470 100644 --- a/lib/towerops_web/live/coverage_live/show.ex +++ b/lib/towerops_web/live/coverage_live/show.ex @@ -5,11 +5,12 @@ defmodule ToweropsWeb.CoverageLive.Show do 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) + 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) @@ -21,7 +22,10 @@ defmodule ToweropsWeb.CoverageLive.Show do |> assign(:organization, organization) |> assign(:coverage, coverage) |> assign(:antenna, antenna) - |> assign(:eirp_dbm, eirp)} + |> assign(:eirp_dbm, eirp) + |> assign(:probe, nil) + |> assign(:probe_units, "ft") + |> assign(:show_params, true)} end @impl true @@ -58,12 +62,42 @@ defmodule ToweropsWeb.CoverageLive.Show do 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) + |> Towerops.Repo.preload([:device, :site]) eirp = Coverage.eirp_dbm( @@ -71,9 +105,22 @@ defmodule ToweropsWeb.CoverageLive.Show do (socket.assigns.antenna && socket.assigns.antenna.gain_dbi) || 0.0 ) - {:noreply, socket |> assign(:coverage, coverage) |> assign(:eirp_dbm, eirp)} + {: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" @@ -126,4 +173,13 @@ defmodule ToweropsWeb.CoverageLive.Show do """ 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 diff --git a/lib/towerops_web/live/coverage_live/show.html.heex b/lib/towerops_web/live/coverage_live/show.html.heex index 6d8719f2..8ac1fd72 100644 --- a/lib/towerops_web/live/coverage_live/show.html.heex +++ b/lib/towerops_web/live/coverage_live/show.html.heex @@ -3,16 +3,18 @@ current_scope={@current_scope} active_page="coverage" > - <.breadcrumb items={[ - %{label: t("Dashboard"), navigate: ~p"/dashboard"}, - %{label: t("Coverage"), navigate: ~p"/coverage"}, - %{label: @coverage.name} - ]} /> - -
-
-

{@coverage.name}

-

+ <%!-- Top bar: title, status, actions. Compact so the map can dominate. --%> +

+
+ <.breadcrumb items={[ + %{label: t("Dashboard"), navigate: ~p"/dashboard"}, + %{label: t("Coverage"), navigate: ~p"/coverage"}, + %{label: @coverage.name} + ]} /> +

+ {@coverage.name} +

+

<.icon name="hero-map-pin" class="h-4 w-4 inline" /> <.link navigate={~p"/sites/#{@coverage.site_id}"} class="hover:underline"> {@coverage.site && @coverage.site.name} @@ -20,13 +22,22 @@

-
+
{@coverage.status} + <.link navigate={~p"/coverage/#{@coverage.id}/edit"} class="btn btn-outline btn-sm gap-1.5"> <.icon name="hero-pencil-square" class="h-4 w-4" /> {t("Edit")} @@ -36,8 +47,9 @@
+ <%!-- Status banners (queued / computing / failed). --%> <%= if @coverage.status == "computing" do %> -
+
<.icon name="hero-arrow-path" @@ -65,7 +77,7 @@ <% end %> <%= if @coverage.status == "failed" do %> -
+
<.icon name="hero-exclamation-triangle" @@ -84,7 +96,7 @@ <% end %> <%= if @coverage.status == "queued" do %> -
+

<.icon name="hero-clock" class="h-4 w-4 inline" /> {t("Coverage is queued. Compute will start shortly.")} @@ -92,151 +104,304 @@

<% end %> -
- <%!-- Map area --%> -
-
- <%= cond do %> - <% @coverage.png_path && @coverage.status == "ready" -> %> -
-
- <%!-- Opacity slider + cnHeat-style legend --%> -
-
- - - - 70% - -
- -
- {t("Signal:")} - - ≥ -55 dBm - - - -65 - - - -75 - - - -85 - - - -95 - - - {t("(weaker is darker)")} - -
-
- <% true -> %> -
-
- <.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" /> -

- {t("No heatmap available yet. Run compute to generate the coverage prediction.")} -

-
-
- <% end %> -
-
- - <%!-- Parameters panel --%> -
-
-

- {t("Parameters")} -

-
- <.param_row - label={t("Antenna")} - value={(@antenna && @antenna.model) || @coverage.antenna_slug} - /> - <.param_row - :if={ - not is_nil(@coverage.device_id) and Ecto.assoc_loaded?(@coverage.device) and - not is_nil(@coverage.device) - } - label={t("Device")} - value={@coverage.device.name || @coverage.device.ip_address} - /> - <.param_row :if={@antenna} label={t("Manufacturer")} value={@antenna.manufacturer} /> - <.param_row :if={@antenna} label={t("Antenna gain")} value={"#{@antenna.gain_dbi} dBi"} /> - <.param_row label={t("Frequency")} value={format_ghz(@coverage.frequency_mhz)} /> - <.param_row label={t("TX power")} value={"#{@coverage.tx_power_dbm} dBm"} /> - <.param_row label={t("EIRP")} value={"#{Float.round(@eirp_dbm, 1)} dBm"} /> - <.param_row label={t("Height above ground")} value={format_ft(@coverage.height_agl_m)} /> - <.param_row label={t("Azimuth")} value={"#{@coverage.azimuth_deg}°"} /> - <.param_row label={t("Tilt")} value={"#{@coverage.downtilt_deg}°"} /> - <.param_row label={t("Range")} value={format_mi(@coverage.radius_m)} /> - <.param_row - :if={@coverage.foliage_tuning && @coverage.foliage_tuning > 0} - label={t("Foliage tuning")} - value={"#{@coverage.foliage_tuning}"} - /> - <.param_row - :if={@coverage.computed_at} - label={t("Computed at")} - value={Calendar.strftime(@coverage.computed_at, "%Y-%m-%d %H:%M UTC")} - /> -
-
- - -
-
+
+ + <%!-- LOS / NLOS toggle + SM install-height slider (left) --%> +
+
+
+ + +
+ + {t("Height above clutter")} + + + 10 ft +
+
+ + <%!-- Parameters / actions panel (right). Toggleable via header button. --%> + <%= if @show_params do %> +
+
+
+

+ {t("Parameters")} +

+ +
+
+
+ <.param_row + label={t("Antenna")} + value={(@antenna && @antenna.model) || @coverage.antenna_slug} + /> + <.param_row + :if={ + not is_nil(@coverage.device_id) and Ecto.assoc_loaded?(@coverage.device) and + not is_nil(@coverage.device) + } + label={t("Device")} + value={@coverage.device.name || @coverage.device.ip_address} + /> + <.param_row + :if={@antenna} + label={t("Manufacturer")} + value={@antenna.manufacturer} + /> + <.param_row + :if={@antenna} + label={t("Antenna gain")} + value={"#{@antenna.gain_dbi} dBi"} + /> + <.param_row label={t("Frequency")} value={format_ghz(@coverage.frequency_mhz)} /> + <.param_row label={t("TX power")} value={"#{@coverage.tx_power_dbm} dBm"} /> + <.param_row label={t("EIRP")} value={"#{Float.round(@eirp_dbm, 1)} dBm"} /> + <.param_row + label={t("Height above ground")} + value={format_ft(@coverage.height_agl_m)} + /> + <.param_row label={t("Azimuth")} value={"#{@coverage.azimuth_deg}°"} /> + <.param_row label={t("Tilt")} value={"#{@coverage.downtilt_deg}°"} /> + <.param_row label={t("Range")} value={format_mi(@coverage.radius_m)} /> + <.param_row + :if={@coverage.foliage_tuning && @coverage.foliage_tuning > 0} + label={t("Foliage tuning")} + value={"#{@coverage.foliage_tuning}"} + /> + <.param_row + :if={@coverage.computed_at} + label={t("Computed at")} + value={Calendar.strftime(@coverage.computed_at, "%Y-%m-%d %H:%M UTC")} + /> +
+ +
+
+
+ <% end %> + + <%!-- Opacity + RSSI filter + legend (bottom-center). --%> +
+
+
+ + + 70% +
+
+ + + + -100 dBm + +
+
+ + ≥-50 + + + -65 + + + -75 + + + -85 + + + ≤-95 dBm +
+
+
+ + <%!-- Distance Tool side panel — opens on map click. --%> + <%= if @probe do %> +
+
+
+
+ <.icon name="hero-map-pin" class="h-4 w-4 text-blue-500" /> + + {:erlang.float_to_binary(@probe.lat * 1.0, decimals: 5)}, {:erlang.float_to_binary( + @probe.lon * 1.0, + decimals: 5 + )} + +
+ +
+
+ {t("Units:")} + + +
+ + + + + + + + + + <%= for row <- @probe.rows do %> + + + + + + <% end %> + +
+ {t("Tower")} + + {t("Distance")} + + RSSI +
+ {row.coverage.name} + + {fmt_distance(row.distance_m, @probe_units)} + + "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)} +
+
+
+ <% end %> + <% true -> %> +
+
+ <.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" /> +

+ {t("No heatmap available yet. Run compute to generate the coverage prediction.")} +

+
+
+ <% end %>