towerops/lib/towerops_web/live/coverage_live/map.html.heex
Graham McIntire ce9bd744c7 feat(coverage): LOS/NLOS dual compute + buildings clutter + MS importer
Closes the cnHeat-parity gap on modes and clutter:

Backend
- Towerops.Coverages.Buildings.for_bbox/1 — PostGIS ST_Intersects
  query that returns each building polygon as a compact
  %{height_m, coords} map ready for in-memory point-in-polygon
  testing, so the per-pixel hot loop never round-trips to
  Postgres.
- Towerops.Coverages.Profile.sample/5 — accepts an optional
  :clutter list and adds the building rooftop height to terrain
  whenever a sample point falls inside a polygon. Pure ray-cast
  PIP for portability.
- Towerops.Workers.CoverageWorker — fetches buildings once per
  job and computes BOTH LOS and NLOS variants per SM-height
  tier (12 PNGs total). LOS passes clutter: [] (height-above-
  clutter view), NLOS passes the prefetched list (height-above-
  ground view with rooftop diffraction). Pixel-loop signature
  bundled into a ctx map to keep the function arity within
  Credo's max-8 limit.
- Towerops.Workers.MsBuildingsImportWorker — streams Microsoft
  GlobalMLBuildingFootprints GeoJSONSeq exports line-by-line,
  upserts into coverage_buildings in 500-row batches via
  insert_all + ON CONFLICT (source, ms_footprint_id). Handles
  Polygon and MultiPolygon geometries; stable-hashes the bbox
  when an explicit feature id is missing so re-imports are
  idempotent. Public import_file/2 lets ops drive a state at
  a time from IEx.
- Schema: nlos_tiers JSONB column on coverages, status_changeset
  whitelist, payload exposed via list_ready_for_organization.

Frontend
- LOS / NLOS toggle pills above the height slider (left panel).
  Selecting NLOS swaps each L.imageOverlay's URL via setUrl to
  the matching nlos_tiers PNG, drops the cached pixel data, and
  re-decodes for the RSSI filter; falls back to the other
  mode's tiers when one is empty so older coverages still
  render. coverage_hooks chunk: 22 KB → 25 KB.
2026-05-06 16:50:05 -05:00

312 lines
12 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.header>
{@page_title}
<:subtitle>
{t("All ready coverages overlaid. Click any point to query each tower's predicted RSSI.")}
</:subtitle>
<:actions>
<.link navigate={~p"/coverage/list"} class="btn btn-outline btn-sm gap-1.5">
<.icon name="hero-list-bullet" class="h-4 w-4" /> {t("List view")}
</.link>
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")}
</.button>
</:actions>
</.header>
<%= if @coverages == [] do %>
<div class="mt-6 flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No ready coverages")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t(
"Coverage maps appear here once the background compute finishes. Create or wait for a coverage to finish."
)}
</p>
<div class="card-actions mt-2">
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
</.button>
</div>
</div>
</div>
</div>
<% else %>
<div
id="coverage-multi-map"
phx-hook="MultiCoverageMap"
phx-update="ignore"
class="relative mt-6 h-[78vh] w-full rounded-lg border border-gray-200 dark:border-white/10 bg-gray-100 dark:bg-gray-800 overflow-hidden"
data-coverages={Jason.encode!(coverages_payload(@coverages))}
data-enabled={Jason.encode!(@enabled_ids)}
>
</div>
<%!-- SM install-height slider (vertical, left side) — cnHeat-style --%>
<div
class="pointer-events-none fixed top-32 left-1/2 -translate-x-[640px] 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">
<%!-- LOS vs NLOS mode toggle. The label and reference frame
of the height slider switch with the mode. --%>
<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>
<%!-- Tower toggle list (top-right, overlay above map) --%>
<div
class="pointer-events-none fixed top-32 right-6 z-[1100] w-72 max-h-[60vh] overflow-y-auto"
id="coverage-tower-list"
>
<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("Towers")}</h3>
<div class="flex gap-1.5">
<button
type="button"
phx-click="show_all"
class="text-xs px-1.5 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
>
{t("Show All")}
</button>
<button
type="button"
phx-click="hide_all"
class="text-xs px-1.5 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
>
{t("Hide All")}
</button>
</div>
</div>
<ul class="py-1">
<%= for c <- @coverages do %>
<li>
<button
type="button"
phx-click="toggle_coverage"
phx-value-id={c.id}
class={[
"w-full text-left flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800",
if(c.id in @enabled_ids,
do: "text-gray-900 dark:text-white font-medium",
else: "text-gray-400 dark:text-gray-500"
)
]}
>
<span class={[
"inline-block h-2.5 w-2.5 rounded-full",
if(c.id in @enabled_ids,
do: "bg-green-500",
else: "bg-gray-300 dark:bg-gray-600"
)
]}>
</span>
<span class="text-sm truncate flex-1">{c.name}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate">
{c.site && c.site.name}
</span>
</button>
</li>
<% end %>
</ul>
</div>
</div>
<%!-- Opacity slider + legend (bottom-center, overlay above map) --%>
<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>
<%!-- Min signal-strength filter — hides pixels weaker than this. --%>
<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 when the user clicks the map --%>
<div
:if={@probe}
class="pointer-events-none fixed top-32 left-6 z-[1100] w-[420px] 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 %>
</Layouts.authenticated>