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.
232 lines
9 KiB
Text
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>
|