towerops/lib/towerops_web/live/coverage_live/show.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

232 lines
9 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
%{label: t("Coverage"), navigate: ~p"/coverage"},
%{label: @coverage.name}
]} />
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@coverage.name}</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<.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}
</.link>
</p>
</div>
<div class="flex items-center gap-2">
<span class={[
"inline-flex items-center px-2.5 py-1 rounded text-sm font-medium",
status_badge_class(@coverage.status)
]}>
{@coverage.status}
</span>
<.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")}
</.link>
<.button phx-click="recompute" variant="primary">
<.icon name="hero-arrow-path" class="h-4 w-4" /> {t("Recompute")}
</.button>
</div>
</div>
<%= 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="flex items-center gap-3">
<.icon
name="hero-arrow-path"
class="h-5 w-5 text-yellow-700 dark:text-yellow-400 animate-spin"
/>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-200">
{t("Computing coverage…")}
</p>
<div class="mt-1 h-2 w-full bg-yellow-200 dark:bg-yellow-900 rounded-full overflow-hidden">
<div
class="h-full bg-yellow-600 dark:bg-yellow-500 transition-all"
style={"width: #{@coverage.progress_pct}%"}
>
</div>
</div>
</div>
<span class="text-sm text-yellow-800 dark:text-yellow-300 font-mono">
{@coverage.progress_pct}%
</span>
</div>
</div>
<% end %>
<%= 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="flex items-start gap-3">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-red-700 dark:text-red-400 flex-shrink-0 mt-0.5"
/>
<div>
<p class="text-sm font-medium text-red-900 dark:text-red-200">
{t("Compute failed")}
</p>
<p :if={@coverage.error_message} class="mt-1 text-sm text-red-800 dark:text-red-300">
{@coverage.error_message}
</p>
</div>
</div>
</div>
<% end %>
<%= 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">
<p class="text-sm text-blue-900 dark:text-blue-200">
<.icon name="hero-clock" class="h-4 w-4 inline" />
{t("Coverage is queued. Compute will start shortly.")}
</p>
</div>
<% end %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<%!-- Map area --%>
<div class="lg:col-span-2">
<div class="rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<%= cond do %>
<% @coverage.png_path && @coverage.status == "ready" -> %>
<div
id={"coverage-map-#{@coverage.id}"}
class="h-[500px] w-full bg-gray-100 dark:bg-gray-800"
phx-hook="CoverageMap"
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={@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")}
</button>
</div>
</div>
</Layouts.authenticated>