Height in feet doesn't need decimal precision. Migrates the DB column, updates schema type, and strips trailing .0 from form input so the integer cast succeeds.
218 lines
7.3 KiB
Elixir
218 lines
7.3 KiB
Elixir
defmodule MicrowavepropWeb.BeaconLive.Show do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Beacons
|
|
alias Microwaveprop.Beacons.Beacon
|
|
alias Microwaveprop.Beacons.RangeEstimate
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
|
<.header>
|
|
{@beacon.callsign}
|
|
<:subtitle>
|
|
{Beacon.format_freq(@beacon.frequency_mhz)} MHz · {@beacon.grid}
|
|
<span :if={not @beacon.approved} class="badge badge-warning badge-sm ml-2">
|
|
Pending approval
|
|
</span>
|
|
</:subtitle>
|
|
<:actions>
|
|
<.button
|
|
:if={admin?(@current_scope) and not @beacon.approved}
|
|
variant="primary"
|
|
phx-click="approve"
|
|
>
|
|
<.icon name="hero-check" /> Approve
|
|
</.button>
|
|
<.button navigate={~p"/beacons"}>
|
|
<.icon name="hero-arrow-left" />
|
|
</.button>
|
|
<.button
|
|
:if={admin?(@current_scope)}
|
|
variant="primary"
|
|
navigate={~p"/beacons/#{@beacon}/edit?return_to=show"}
|
|
>
|
|
<.icon name="hero-pencil-square" /> Edit
|
|
</.button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div
|
|
id={"beacon-map-" <> @beacon.id}
|
|
phx-hook="BeaconMap"
|
|
phx-update="ignore"
|
|
class="h-96 w-full rounded-lg border border-base-300 mb-2"
|
|
data-lat={@beacon.lat}
|
|
data-lon={@beacon.lon}
|
|
data-label={"#{@beacon.callsign} · #{Beacon.format_freq(@beacon.frequency_mhz)} MHz"}
|
|
data-on-the-air={to_string(@beacon.on_the_air)}
|
|
data-grid-step={@estimate.grid_step}
|
|
data-cells={Jason.encode!(@estimate.cells)}
|
|
data-show-coverage={to_string(@show_coverage)}
|
|
>
|
|
</div>
|
|
|
|
<div class="mb-2 flex items-center justify-between flex-wrap gap-2">
|
|
<label class="label cursor-pointer gap-2 py-0">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary"
|
|
checked={@show_coverage}
|
|
phx-click="toggle_coverage"
|
|
/>
|
|
<span class="label-text text-sm">Show estimated current coverage</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div :if={@show_coverage} class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3">
|
|
<span>
|
|
Band <strong>{@estimate.band_label}</strong> ·
|
|
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
|
Atm loss <strong>{@estimate.atm_per_km} dB/km</strong>
|
|
</span>
|
|
<span>
|
|
Beacon grid score <strong>{@estimate.center_score}</strong>
|
|
<%= if @estimate.valid_time do %>
|
|
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
<% else %>
|
|
(no HRRR data — defaulting cells to 50)
|
|
<% end %>
|
|
· <strong>{length(@estimate.cells)}</strong>
|
|
cells rendered
|
|
</span>
|
|
</div>
|
|
|
|
<div :if={@show_coverage} class="flex flex-wrap gap-3 text-xs mb-4">
|
|
<%= for tier <- @estimate.tiers do %>
|
|
<div class="flex items-center gap-1.5">
|
|
<span
|
|
class="inline-block w-3 h-3 rounded-sm border"
|
|
style={"background-color: #{tier.color}; border-color: #{tier.color}"}
|
|
>
|
|
</span>
|
|
<span>
|
|
<strong>{tier.label}</strong> (≥ {tier.min_dbm} dBm)
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-base-300 bg-base-200/40 p-3 text-sm">
|
|
<dl class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-4 gap-y-2">
|
|
<.stat_field label="Frequency">{Beacon.format_freq(@beacon.frequency_mhz)} MHz</.stat_field>
|
|
<.stat_field label="Callsign">{@beacon.callsign}</.stat_field>
|
|
<.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)}</.stat_field>
|
|
<.stat_field label="On the air">
|
|
<span class={[
|
|
"badge badge-sm",
|
|
(@beacon.on_the_air && "badge-success") || "badge-ghost"
|
|
]}>
|
|
{if @beacon.on_the_air, do: "Yes", else: "No"}
|
|
</span>
|
|
</.stat_field>
|
|
|
|
<.stat_field label="Grid">{@beacon.grid}</.stat_field>
|
|
<.stat_field label="Latitude">{format_coord(@beacon.lat)}</.stat_field>
|
|
<.stat_field label="Longitude">{format_coord(@beacon.lon)}</.stat_field>
|
|
<.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW</.stat_field>
|
|
|
|
<.stat_field label="Height AGL">{@beacon.height_ft} ft</.stat_field>
|
|
<.stat_field label="Bearing">{bearing_label(@beacon.bearing)}</.stat_field>
|
|
<.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth">
|
|
{Beacon.format_mw(@beacon.beamwidth_deg)}°
|
|
</.stat_field>
|
|
</dl>
|
|
|
|
<div :if={@beacon.notes && @beacon.notes != ""} class="mt-3 pt-3 border-t border-base-300">
|
|
<div class="font-semibold text-[11px] uppercase tracking-wider opacity-60 mb-1">
|
|
Notes
|
|
</div>
|
|
<div class="whitespace-pre-wrap text-sm">{@beacon.notes}</div>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
if connected?(socket), do: Beacons.subscribe_beacons()
|
|
|
|
beacon = Beacons.get_beacon!(id)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Beacon")
|
|
|> assign(:beacon, beacon)
|
|
|> assign(:show_coverage, false)
|
|
|> assign(:estimate, RangeEstimate.estimate(beacon))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_coverage", _params, socket) do
|
|
show = not socket.assigns.show_coverage
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_coverage, show)
|
|
|> push_event("toggle_coverage", %{show: show})}
|
|
end
|
|
|
|
def handle_event("approve", _params, socket) do
|
|
if admin?(socket.assigns.current_scope) do
|
|
{:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:beacon, approved)
|
|
|> put_flash(:info, "Beacon approved.")}
|
|
else
|
|
{:noreply, put_flash(socket, :error, "Admins only.")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:beacon, beacon)
|
|
|> assign(:estimate, RangeEstimate.estimate(beacon))}
|
|
end
|
|
|
|
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, "This beacon was deleted.")
|
|
|> push_navigate(to: ~p"/beacons")}
|
|
end
|
|
|
|
def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp admin?(%{user: %{is_admin: true}}), do: true
|
|
defp admin?(_), do: false
|
|
|
|
defp bearing_label(nil), do: "Omni"
|
|
defp bearing_label("omni"), do: "Omni"
|
|
defp bearing_label(value), do: "#{value}°"
|
|
|
|
defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 6)
|
|
defp format_coord(value), do: to_string(value)
|
|
|
|
attr :label, :string, required: true
|
|
slot :inner_block, required: true
|
|
|
|
defp stat_field(assigns) do
|
|
~H"""
|
|
<div class="min-w-0">
|
|
<dt class="font-semibold text-[11px] uppercase tracking-wider opacity-60">
|
|
{@label}
|
|
</dt>
|
|
<dd class="truncate">{render_slot(@inner_block)}</dd>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|