feat(coverage): cnHeat-style full-bleed map on the show page
Rewire the single-coverage show page to mount MultiCoverageMap (the same hook the org map uses) with one coverage in the array, and lift the cnHeat-style overlay panels — LOS/NLOS toggle + height slider on the left, opacity + RSSI filter on the bottom, distance probe on click. Parameters move into a togglable right-side drawer so the map dominates the viewport. Show LiveView gains toggle_params / probe_point / close_probe / set_probe_units handlers and delegates the formatters to CoverageLive.Map. Site is now preloaded so coverages_payload/1 can read site.name without an Ecto.AssociationNotLoaded crash that was silently nuking the data-coverages JSON encode. Also fixes a real bug in coverage_hooks.ts: pngForCoverage was returning before the antenna-marker creation block, leaving the marker code as unreachable dead code — towers never rendered. Moved the marker creation into addCoverageLayer where it belongs and added a no-PNG guard so a missing png_path logs instead of silently failing the L.imageOverlay constructor.
This commit is contained in:
parent
98da879399
commit
4e391b3ecf
3 changed files with 415 additions and 189 deletions
|
|
@ -503,12 +503,42 @@ export const MultiCoverageMap = {
|
||||||
addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) {
|
addCoverageLayer(this: MultiCoverageMapState, c: CoveragePayload) {
|
||||||
const [minLat, minLon, maxLat, maxLon] = c.bbox
|
const [minLat, minLon, maxLat, maxLon] = c.bbox
|
||||||
const png = this.pngForCoverage(c)
|
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]], {
|
const overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], {
|
||||||
opacity: this.currentOpacity(),
|
opacity: this.currentOpacity(),
|
||||||
interactive: false,
|
interactive: false,
|
||||||
})
|
})
|
||||||
this.overlays.set(c.id, overlay)
|
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: `
|
||||||
|
<div style="position:relative;width:32px;height:32px">
|
||||||
|
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
|
||||||
|
transform:translate(-50%,-100%) rotate(${az}deg);
|
||||||
|
transform-origin:50% 100%;
|
||||||
|
border-left:18px solid transparent;
|
||||||
|
border-right:18px solid transparent;
|
||||||
|
border-bottom:36px solid rgba(59,130,246,0.35);"></div>
|
||||||
|
<div style="position:absolute;top:50%;left:50%;width:12px;height:12px;
|
||||||
|
transform:translate(-50%,-50%);
|
||||||
|
background:#2563eb;border:2px solid white;border-radius:50%;
|
||||||
|
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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
|
// Pre-decode the heatmap into ImageData so the RSSI slider can
|
||||||
// re-mask client-side without a server round-trip.
|
// re-mask client-side without a server round-trip.
|
||||||
void this.loadCoverageImage(c)
|
void this.loadCoverageImage(c)
|
||||||
|
|
@ -540,31 +570,6 @@ export const MultiCoverageMap = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best || c.png
|
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: `
|
|
||||||
<div style="position:relative;width:32px;height:32px">
|
|
||||||
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
|
|
||||||
transform:translate(-50%,-100%) rotate(${az}deg);
|
|
||||||
transform-origin:50% 100%;
|
|
||||||
border-left:18px solid transparent;
|
|
||||||
border-right:18px solid transparent;
|
|
||||||
border-bottom:36px solid rgba(59,130,246,0.35);"></div>
|
|
||||||
<div style="position:absolute;top:50%;left:50%;width:12px;height:12px;
|
|
||||||
transform:translate(-50%,-50%);
|
|
||||||
background:#2563eb;border:2px solid white;border-radius:50%;
|
|
||||||
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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) {
|
applyEnabled(this: MultiCoverageMapState) {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ defmodule ToweropsWeb.CoverageLive.Show do
|
||||||
alias Towerops.Coverages
|
alias Towerops.Coverages
|
||||||
alias Towerops.Coverages.Antenna
|
alias Towerops.Coverages.Antenna
|
||||||
alias Towerops.Coverages.Coverage
|
alias Towerops.Coverages.Coverage
|
||||||
|
alias ToweropsWeb.CoverageLive.Map, as: CoverageMapView
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
organization = socket.assigns.current_scope.organization
|
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)
|
antenna = Antenna.get(coverage.antenna_slug)
|
||||||
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
|
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
|
||||||
|
|
||||||
|
|
@ -21,7 +22,10 @@ defmodule ToweropsWeb.CoverageLive.Show do
|
||||||
|> assign(:organization, organization)
|
|> assign(:organization, organization)
|
||||||
|> assign(:coverage, coverage)
|
|> assign(:coverage, coverage)
|
||||||
|> assign(:antenna, antenna)
|
|> assign(:antenna, antenna)
|
||||||
|> assign(:eirp_dbm, eirp)}
|
|> assign(:eirp_dbm, eirp)
|
||||||
|
|> assign(:probe, nil)
|
||||||
|
|> assign(:probe_units, "ft")
|
||||||
|
|> assign(:show_params, true)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -58,12 +62,42 @@ defmodule ToweropsWeb.CoverageLive.Show do
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def handle_info({:coverage_status, _status, _extra}, socket) do
|
def handle_info({:coverage_status, _status, _extra}, socket) do
|
||||||
coverage =
|
coverage =
|
||||||
socket.assigns.organization.id
|
socket.assigns.organization.id
|
||||||
|> Coverages.get_coverage!(socket.assigns.coverage.id)
|
|> Coverages.get_coverage!(socket.assigns.coverage.id)
|
||||||
|> Towerops.Repo.preload(:device)
|
|> Towerops.Repo.preload([:device, :site])
|
||||||
|
|
||||||
eirp =
|
eirp =
|
||||||
Coverage.eirp_dbm(
|
Coverage.eirp_dbm(
|
||||||
|
|
@ -71,9 +105,22 @@ defmodule ToweropsWeb.CoverageLive.Show do
|
||||||
(socket.assigns.antenna && socket.assigns.antenna.gain_dbi) || 0.0
|
(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
|
end
|
||||||
|
|
||||||
|
# -- helpers (template) --
|
||||||
|
|
||||||
|
defdelegate coverages_payload(coverages), to: CoverageMapView
|
||||||
|
defdelegate fmt_distance(m, units), to: CoverageMapView
|
||||||
|
defdelegate fmt_rssi(v), to: CoverageMapView
|
||||||
|
|
||||||
@doc false
|
@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("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
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="coverage"
|
active_page="coverage"
|
||||||
>
|
>
|
||||||
<.breadcrumb items={[
|
<%!-- Top bar: title, status, actions. Compact so the map can dominate. --%>
|
||||||
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
|
<div class="mb-4 flex items-start justify-between gap-4">
|
||||||
%{label: t("Coverage"), navigate: ~p"/coverage"},
|
<div class="min-w-0">
|
||||||
%{label: @coverage.name}
|
<.breadcrumb items={[
|
||||||
]} />
|
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
|
||||||
|
%{label: t("Coverage"), navigate: ~p"/coverage"},
|
||||||
<div class="mb-6 flex items-start justify-between gap-4">
|
%{label: @coverage.name}
|
||||||
<div>
|
]} />
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@coverage.name}</h1>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white truncate">
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
{@coverage.name}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<.icon name="hero-map-pin" class="h-4 w-4 inline" />
|
<.icon name="hero-map-pin" class="h-4 w-4 inline" />
|
||||||
<.link navigate={~p"/sites/#{@coverage.site_id}"} class="hover:underline">
|
<.link navigate={~p"/sites/#{@coverage.site_id}"} class="hover:underline">
|
||||||
{@coverage.site && @coverage.site.name}
|
{@coverage.site && @coverage.site.name}
|
||||||
|
|
@ -20,13 +22,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<span class={[
|
<span class={[
|
||||||
"inline-flex items-center px-2.5 py-1 rounded text-sm font-medium",
|
"inline-flex items-center px-2.5 py-1 rounded text-sm font-medium",
|
||||||
status_badge_class(@coverage.status)
|
status_badge_class(@coverage.status)
|
||||||
]}>
|
]}>
|
||||||
{@coverage.status}
|
{@coverage.status}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_params"
|
||||||
|
class="btn btn-outline btn-sm gap-1.5"
|
||||||
|
title={t("Toggle parameters panel")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-adjustments-horizontal" class="h-4 w-4" />
|
||||||
|
{if @show_params, do: t("Hide details"), else: t("Details")}
|
||||||
|
</button>
|
||||||
<.link navigate={~p"/coverage/#{@coverage.id}/edit"} class="btn btn-outline btn-sm gap-1.5">
|
<.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")}
|
<.icon name="hero-pencil-square" class="h-4 w-4" /> {t("Edit")}
|
||||||
</.link>
|
</.link>
|
||||||
|
|
@ -36,8 +47,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%!-- Status banners (queued / computing / failed). --%>
|
||||||
<%= if @coverage.status == "computing" do %>
|
<%= if @coverage.status == "computing" do %>
|
||||||
<div class="mb-6 rounded-lg border border-yellow-200 bg-yellow-50 dark:border-yellow-900/50 dark:bg-yellow-900/20 p-4">
|
<div class="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 dark:border-yellow-900/50 dark:bg-yellow-900/20 p-4">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<.icon
|
<.icon
|
||||||
name="hero-arrow-path"
|
name="hero-arrow-path"
|
||||||
|
|
@ -65,7 +77,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @coverage.status == "failed" do %>
|
<%= if @coverage.status == "failed" do %>
|
||||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-900/20 p-4">
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-900/20 p-4">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<.icon
|
<.icon
|
||||||
name="hero-exclamation-triangle"
|
name="hero-exclamation-triangle"
|
||||||
|
|
@ -84,7 +96,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @coverage.status == "queued" do %>
|
<%= if @coverage.status == "queued" do %>
|
||||||
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20 p-4">
|
<div class="mb-4 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20 p-4">
|
||||||
<p class="text-sm text-blue-900 dark:text-blue-200">
|
<p class="text-sm text-blue-900 dark:text-blue-200">
|
||||||
<.icon name="hero-clock" class="h-4 w-4 inline" />
|
<.icon name="hero-clock" class="h-4 w-4 inline" />
|
||||||
{t("Coverage is queued. Compute will start shortly.")}
|
{t("Coverage is queued. Compute will start shortly.")}
|
||||||
|
|
@ -92,151 +104,304 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<%!-- Map area. cnHeat-style: full-bleed, controls float above as panels. --%>
|
||||||
<%!-- Map area --%>
|
<%= cond do %>
|
||||||
<div class="lg:col-span-2">
|
<% @coverage.png_path && @coverage.status == "ready" -> %>
|
||||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
<div
|
||||||
<%= cond do %>
|
id="coverage-multi-map"
|
||||||
<% @coverage.png_path && @coverage.status == "ready" -> %>
|
phx-hook="MultiCoverageMap"
|
||||||
<div
|
phx-update="ignore"
|
||||||
id={"coverage-map-#{@coverage.id}"}
|
class="relative h-[78vh] w-full rounded-lg border border-gray-200 dark:border-white/10 bg-gray-100 dark:bg-gray-800 overflow-hidden"
|
||||||
class="h-[500px] w-full bg-gray-100 dark:bg-gray-800"
|
data-coverages={Jason.encode!(coverages_payload([@coverage]))}
|
||||||
phx-hook="CoverageMap"
|
data-enabled={Jason.encode!([@coverage.id])}
|
||||||
phx-update="ignore"
|
|
||||||
data-name={@coverage.name}
|
|
||||||
data-lat={coverage_lat(@coverage)}
|
|
||||||
data-lon={coverage_lon(@coverage)}
|
|
||||||
data-azimuth={@coverage.azimuth_deg}
|
|
||||||
data-png={@coverage.png_path}
|
|
||||||
data-bbox={
|
|
||||||
Jason.encode!([
|
|
||||||
@coverage.bbox_min_lat,
|
|
||||||
@coverage.bbox_min_lon,
|
|
||||||
@coverage.bbox_max_lat,
|
|
||||||
@coverage.bbox_max_lon
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<%!-- Opacity slider + cnHeat-style legend --%>
|
|
||||||
<div class="p-3 border-t border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 space-y-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<label
|
|
||||||
for="coverage-opacity-slider"
|
|
||||||
class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t("Overlay opacity")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="coverage-opacity-slider"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="5"
|
|
||||||
value="70"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
id="coverage-opacity-label"
|
|
||||||
class="text-sm font-mono text-gray-700 dark:text-gray-300 w-12 text-right"
|
|
||||||
>
|
|
||||||
70%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1 text-xs">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400 mr-2">{t("Signal:")}</span>
|
|
||||||
<span class="px-2 py-0.5 rounded text-white" style="background:rgba(0,200,0,0.9)">
|
|
||||||
≥ -55 dBm
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="px-2 py-0.5 rounded text-white"
|
|
||||||
style="background:rgba(200,230,0,0.9)"
|
|
||||||
>
|
|
||||||
-65
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="px-2 py-0.5 rounded text-white"
|
|
||||||
style="background:rgba(255,165,0,0.9)"
|
|
||||||
>
|
|
||||||
-75
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="px-2 py-0.5 rounded text-white"
|
|
||||||
style="background:rgba(220,60,60,0.9)"
|
|
||||||
>
|
|
||||||
-85
|
|
||||||
</span>
|
|
||||||
<span class="px-2 py-0.5 rounded text-white" style="background:rgba(100,0,0,0.9)">
|
|
||||||
-95
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400 ml-1">
|
|
||||||
{t("(weaker is darker)")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% true -> %>
|
|
||||||
<div class="h-[500px] w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900 text-center px-6">
|
|
||||||
<div>
|
|
||||||
<.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" />
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{t("No heatmap available yet. Run compute to generate the coverage prediction.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Parameters panel --%>
|
|
||||||
<div>
|
|
||||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
|
||||||
{t("Parameters")}
|
|
||||||
</h3>
|
|
||||||
<dl class="space-y-2 text-sm">
|
|
||||||
<.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")}
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="delete"
|
|
||||||
data-confirm={t("Delete this coverage?")}
|
|
||||||
class="mt-4 w-full text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="h-4 w-4 inline" /> {t("Delete coverage")}
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
<%!-- LOS / NLOS toggle + SM install-height slider (left) --%>
|
||||||
</div>
|
<div
|
||||||
|
class="pointer-events-none fixed top-32 left-6 z-[1100]"
|
||||||
|
id="coverage-height-control"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-3 flex flex-col items-center gap-2 w-24">
|
||||||
|
<div class="flex w-full text-[10px] font-medium border border-gray-200 dark:border-white/10 rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="coverage-mode-los"
|
||||||
|
data-mode="los"
|
||||||
|
class="flex-1 px-1 py-1 text-gray-900 dark:text-white bg-blue-100 dark:bg-blue-900/40"
|
||||||
|
>
|
||||||
|
LOS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="coverage-mode-nlos"
|
||||||
|
data-mode="nlos"
|
||||||
|
class="flex-1 px-1 py-1 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
NLOS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
id="coverage-mode-label"
|
||||||
|
class="text-[10px] font-medium text-gray-700 dark:text-gray-300 text-center leading-tight"
|
||||||
|
>
|
||||||
|
{t("Height above clutter")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="coverage-height-slider"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
step="1"
|
||||||
|
value="1"
|
||||||
|
class="cursor-pointer"
|
||||||
|
style="writing-mode: vertical-lr; direction: rtl; height: 200px; width: 24px;"
|
||||||
|
/>
|
||||||
|
<span id="coverage-height-label" class="text-xs font-mono text-center">10 ft</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Parameters / actions panel (right). Toggleable via header button. --%>
|
||||||
|
<%= if @show_params do %>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed top-32 right-6 z-[1100] w-80 max-h-[70vh] overflow-y-auto"
|
||||||
|
id="coverage-params-panel"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t("Parameters")}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_params"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-1">
|
||||||
|
<dl class="space-y-1.5 text-sm">
|
||||||
|
<.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")}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="delete"
|
||||||
|
data-confirm={t("Delete this coverage?")}
|
||||||
|
class="mt-3 w-full text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="h-4 w-4 inline" /> {t("Delete coverage")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Opacity + RSSI filter + legend (bottom-center). --%>
|
||||||
|
<div class="pointer-events-none fixed bottom-6 left-1/2 -translate-x-1/2 z-[1100] w-[640px] max-w-[90vw]">
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-3 space-y-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
for="coverage-opacity-slider"
|
||||||
|
class="text-xs font-medium text-gray-700 dark:text-gray-300 w-16"
|
||||||
|
>
|
||||||
|
{t("Opacity")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="coverage-opacity-slider"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value="70"
|
||||||
|
class="flex-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span id="coverage-opacity-label" class="text-xs font-mono w-10 text-right">70%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
for="coverage-rssi-slider"
|
||||||
|
class="text-xs font-medium text-gray-700 dark:text-gray-300 w-16"
|
||||||
|
>
|
||||||
|
{t("Min RSSI")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="coverage-rssi-slider"
|
||||||
|
type="range"
|
||||||
|
min="-100"
|
||||||
|
max="-40"
|
||||||
|
step="1"
|
||||||
|
value="-100"
|
||||||
|
class="flex-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span id="coverage-rssi-label" class="text-xs font-mono w-16 text-right">
|
||||||
|
-100 dBm
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-[10px] font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
<span class="w-3 h-3 rounded inline-block" style="background:rgb(0,200,0)"></span>
|
||||||
|
<span>≥-50</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(200,230,0)">
|
||||||
|
</span>
|
||||||
|
<span>-65</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(255,165,0)">
|
||||||
|
</span>
|
||||||
|
<span>-75</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(220,60,60)">
|
||||||
|
</span>
|
||||||
|
<span>-85</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(100,0,0)">
|
||||||
|
</span>
|
||||||
|
<span>≤-95 dBm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Distance Tool side panel — opens on map click. --%>
|
||||||
|
<%= if @probe do %>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed top-32 left-32 z-[1100] w-[360px] max-w-[90vw] max-h-[60vh] overflow-y-auto"
|
||||||
|
id="coverage-probe-panel"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
<.icon name="hero-map-pin" class="h-4 w-4 text-blue-500" />
|
||||||
|
<span>
|
||||||
|
{:erlang.float_to_binary(@probe.lat * 1.0, decimals: 5)}, {:erlang.float_to_binary(
|
||||||
|
@probe.lon * 1.0,
|
||||||
|
decimals: 5
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="close_probe"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2 border-b border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-700 dark:text-gray-300">{t("Units:")}</span>
|
||||||
|
<label class="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="probe-units"
|
||||||
|
checked={@probe_units == "ft"}
|
||||||
|
phx-click="set_probe_units"
|
||||||
|
phx-value-units="ft"
|
||||||
|
/> ft
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="probe-units"
|
||||||
|
checked={@probe_units == "m"}
|
||||||
|
phx-click="set_probe_units"
|
||||||
|
phx-value-units="m"
|
||||||
|
/> m
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="min-w-full text-xs">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-1.5 text-left font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{t("Tower")}
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-1.5 text-right font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{t("Distance")}
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-1.5 text-right font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
RSSI
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||||
|
<%= for row <- @probe.rows do %>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{row.coverage.name}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-1.5 text-right font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
{fmt_distance(row.distance_m, @probe_units)}
|
||||||
|
</td>
|
||||||
|
<td class={[
|
||||||
|
"px-3 py-1.5 text-right font-mono",
|
||||||
|
cond do
|
||||||
|
row.rssi == :no_coverage ->
|
||||||
|
"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)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% true -> %>
|
||||||
|
<div class="h-[78vh] w-full rounded-lg border border-gray-200 dark:border-white/10 flex items-center justify-center bg-gray-50 dark:bg-gray-900 text-center px-6">
|
||||||
|
<div>
|
||||||
|
<.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{t("No heatmap available yet. Run compute to generate the coverage prediction.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</Layouts.authenticated>
|
</Layouts.authenticated>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue