- Replace gray->cool-steel, blue/indigo->cerulean, red->sweet-salmon, yellow/amber->wheat - Dark sidebar: sidebar/footer use cool-steel-800 bg, text lightened for contrast - ~11,600 color replacements across ~99 files - Update tests for new color class names
318 lines
13 KiB
Text
318 lines
13 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="coverage"
|
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
|
>
|
|
<.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 text-sm !py-1 !px-3 border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 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 shadow-md border dark:border-white/10 max-w-md w-full">
|
|
<div class="items-center text-center">
|
|
<div class="rounded-full bg-cerulean-100 dark:bg-cerulean-900/40 p-4 mb-2">
|
|
<.icon name="hero-signal" class="h-12 w-12 text-cerulean-500 dark:text-cerulean-400" />
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-cool-steel-900 dark:text-white">
|
|
{t("No ready coverages")}
|
|
</h3>
|
|
<p class="text-sm text-cool-steel-600 dark:text-cool-steel-400">
|
|
{t(
|
|
"Coverage maps appear here once the background compute finishes. Create or wait for a coverage to finish."
|
|
)}
|
|
</p>
|
|
<div class="mt-4 flex gap-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-cool-steel-200 dark:border-white/10 bg-cool-steel-100 dark:bg-cool-steel-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-cool-steel-200 dark:border-white/10 bg-white/95 dark:bg-cool-steel-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-cool-steel-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-cool-steel-900 dark:text-white bg-cerulean-100 dark:bg-cerulean-900/40"
|
|
>
|
|
LOS
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="coverage-mode-nlos"
|
|
data-mode="nlos"
|
|
class="flex-1 px-1 py-1 text-cool-steel-700 dark:text-cool-steel-300 hover:bg-cool-steel-100 dark:hover:bg-cool-steel-800"
|
|
>
|
|
NLOS
|
|
</button>
|
|
</div>
|
|
<span
|
|
id="coverage-mode-label"
|
|
class="text-[10px] font-medium text-cool-steel-700 dark:text-cool-steel-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-cool-steel-200 dark:border-white/10 bg-white/95 dark:bg-cool-steel-900/95 backdrop-blur shadow-lg">
|
|
<div class="flex items-center justify-between px-3 py-2 border-b border-cool-steel-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-cool-steel-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-cool-steel-100 dark:hover:bg-cool-steel-800 text-cool-steel-700 dark:text-cool-steel-300"
|
|
>
|
|
{t("Show All")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="hide_all"
|
|
class="text-xs px-1.5 py-0.5 rounded hover:bg-cool-steel-100 dark:hover:bg-cool-steel-800 text-cool-steel-700 dark:text-cool-steel-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-cool-steel-50 dark:hover:bg-cool-steel-800",
|
|
if(c.id in @enabled_ids,
|
|
do: "text-cool-steel-900 dark:text-white font-medium",
|
|
else: "text-cool-steel-400 dark:text-cool-steel-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-cool-steel-300 dark:bg-cool-steel-600"
|
|
)
|
|
]}>
|
|
</span>
|
|
<span class="text-sm truncate flex-1">{c.name}</span>
|
|
<span class="text-[10px] text-cool-steel-500 dark:text-cool-steel-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-cool-steel-200 dark:border-white/10 bg-white/95 dark:bg-cool-steel-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-cool-steel-700 dark:text-cool-steel-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-cool-steel-700 dark:text-cool-steel-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-cool-steel-700 dark:text-cool-steel-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-cool-steel-200 dark:border-white/10 bg-white/95 dark:bg-cool-steel-900/95 backdrop-blur shadow-lg">
|
|
<div class="flex items-center justify-between px-3 py-2 border-b border-cool-steel-200 dark:border-white/10">
|
|
<div class="flex items-center gap-2 text-xs font-mono text-cool-steel-700 dark:text-cool-steel-300">
|
|
<.icon name="hero-map-pin" class="h-4 w-4 text-cerulean-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-cool-steel-500 hover:text-cool-steel-700 dark:text-cool-steel-400"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div class="px-3 py-2 border-b border-cool-steel-200 dark:border-white/10 flex items-center gap-3">
|
|
<span class="text-xs text-cool-steel-700 dark:text-cool-steel-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-cool-steel-50 dark:bg-cool-steel-800">
|
|
<tr>
|
|
<th class="px-3 py-1.5 text-left font-medium text-cool-steel-700 dark:text-cool-steel-300">
|
|
{t("Tower")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right font-medium text-cool-steel-700 dark:text-cool-steel-300">
|
|
{t("Distance")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right font-medium text-cool-steel-700 dark:text-cool-steel-300">
|
|
RSSI
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-cool-steel-200 dark:divide-white/10">
|
|
<%= for row <- @probe.rows do %>
|
|
<tr>
|
|
<td class="px-3 py-1.5 font-medium text-cool-steel-900 dark:text-cool-steel-100 truncate">
|
|
{row.coverage.name}
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right font-mono text-cool-steel-700 dark:text-cool-steel-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-cool-steel-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-wheat-600 dark:text-wheat-400"
|
|
|
|
is_number(row.rssi) ->
|
|
"text-sweet-salmon-600 dark:text-sweet-salmon-400"
|
|
|
|
true ->
|
|
"text-cool-steel-400"
|
|
end
|
|
]}>
|
|
{fmt_rssi(row.rssi)}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</Layouts.authenticated>
|