towerops/lib/towerops_web/live/coverage_live/index.html.heex
Graham McIntire 1b6a548f3a refactor: imperial coverage form + Deygout/curvature propagation
Form simplification per user feedback. Form now shows only the
essentials (Name, Site, Antenna, Frequency, TX power, Range, Height,
Azimuth, Tilt, Foliage tuning) with imperial units throughout (ft, mi,
GHz). Defaults: TX power 18 dBm, height 100 ft, azimuth 0°, frequency
5.8 GHz, range 4 mi.

EIRP is calculated, not entered: shown as a live-updating badge in
the form header as `TX power + antenna.gain`.

Removed from the form (still on the schema with sensible defaults so
existing callers and the worker keep working): cell_size_m (auto-
computed from radius / 200, clamped 5–50 m), cable_loss_db, sm_gain_dbi,
tx_clearance_m, height_above_rooftop_m, receiver_height_m,
rx_threshold_dbm, latitude/longitude_override.

Schema:
- Six virtual imperial fields (height_agl_ft, height_above_rooftop_ft,
  receiver_height_ft, tx_clearance_ft, radius_mi, frequency_ghz). The
  changeset converts to SI before validation. Tests/workers/API keep
  sending SI directly.
- ensure_cell_size_m/1: defaults the cell size when callers omit it.

Show + index pages now display GHz / ft / mi.

Propagation accuracy upgrade:
- Earth-curvature correction (4/3-Earth model, ITU-R P.453) applied
  per profile sample. Important for paths over ~5 km.
- Replaced Bullington single-knife-edge with Deygout three-edge
  multi-obstacle method (ITU-R P.526 §4.5): catches multi-ridge
  terrain shadowing that a single dominant obstacle would miss.

Tests: 67 coverage tests pass (added Earth-curvature and Deygout
multi-obstacle reference checks). Full suite: 10817 tests.
2026-05-06 14:44:23 -05:00

166 lines
7.5 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.header>
{@page_title}
<:subtitle>
{t(
"Predict signal coverage for each transmit antenna using LIDAR terrain and building data."
)}
</:subtitle>
<:actions>
<.button :if={@sites != []} navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")}
</.button>
</:actions>
</.header>
<%= cond do %>
<% @sites == [] -> %>
<div class="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("Add a site first")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t(
"Coverage predictions are computed from a site's location. Add a site, then return here to plan its coverage."
)}
</p>
<div class="card-actions mt-2">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Site")}
</.button>
</div>
</div>
</div>
</div>
<% @coverages == [] -> %>
<div class="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 coverages yet")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("Create your first coverage to predict where a tower's antenna reaches.")}
</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>
<% true -> %>
<div class="mt-6 overflow-x-auto rounded-lg border border-gray-200 dark:border-white/10">
<table
id="coverages-table"
class="min-w-full divide-y divide-gray-200 dark:divide-white/10"
>
<thead class="bg-gray-50 dark:bg-gray-800/80">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Site")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Antenna")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Freq (GHz)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("TX (dBm)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Range (mi)")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Status")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-900 dark:divide-white/10">
<%= for coverage <- @coverages do %>
<tr id={"coverage-#{coverage.id}"}>
<td class="px-4 py-2 text-sm">
<.link
navigate={~p"/coverage/#{coverage.id}"}
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{coverage.name}
</.link>
</td>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
<.link navigate={~p"/sites/#{coverage.site_id}"} class="hover:underline">
{coverage.site && coverage.site.name}
</.link>
</td>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
<span class="font-mono text-xs">{coverage.antenna_slug}</span>
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
{Float.round(coverage.frequency_mhz / 1000.0, 3)}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.tx_power_dbm}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
{Float.round(coverage.radius_m / 1609.344, 2)}
</td>
<td class="px-4 py-2 text-sm">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
status_badge_class(coverage.status)
]}>
{coverage.status}
</span>
</td>
<td class="px-4 py-2 text-sm text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/coverage/#{coverage.id}/edit"}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<.icon name="hero-pencil-square" class="h-4 w-4" />
</.link>
<button
type="button"
phx-click="recompute"
phx-value-id={coverage.id}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
title={t("Recompute")}
>
<.icon name="hero-arrow-path" class="h-4 w-4" />
</button>
<button
type="button"
phx-click="delete"
phx-value-id={coverage.id}
data-confirm={t("Delete this coverage?")}
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
title={t("Delete")}
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.authenticated>