prop/lib/microwaveprop_web/live/beacon_live/show.ex
Graham McIntire cda54e3514 Per-HRRR-cell beacon reception plot
Replace the idealised concentric-circle range rings with a realistic
per-HRRR-grid-cell reception map. For every 0.125° grid point within
the band's exceptional range, the estimator computes great-circle
distance, FSPL + atmospheric loss, and applies a cell-specific score
adjustment of (50 - score) * 0.3 dB — score 100 gives a 15 dB ducting
boost, score 0 a 15 dB penalty. Cells below the -145 dBm detection
floor are dropped.

The beacon map hook now renders each surviving cell as a 0.125°
filled rectangle with a tooltip showing tier, Rx dBm, distance, and
HRRR score, so the coverage footprint bulges where ducting conditions
are good and cuts off where they aren't, instead of being a perfect
circle. Falls back to score 50 for cells that don't have HRRR data
yet, so the plot is always useful.

Also fixes the prior all-grey-tiles regression by restoring an
explicit setView() at map creation and adding a post-mount
invalidateSize() for when the map is placed inside a flex container.
2026-04-08 13:43:01 -05:00

125 lines
4.1 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.frequency_mhz} MHz &middot; {@beacon.grid}</:subtitle>
<:actions>
<.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.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)}
>
</div>
<div class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3">
<span>
Band <strong>{@estimate.band_label}</strong> &middot;
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> &middot;
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 %>
&middot; <strong>{length(@estimate.cells)}</strong> cells rendered
</span>
</div>
<div 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>
<.list>
<:item title="Frequency (MHz)">{@beacon.frequency_mhz}</:item>
<:item title="Callsign">{@beacon.callsign}</:item>
<:item title="Grid">{@beacon.grid}</:item>
<:item title="Latitude">{@beacon.lat}</:item>
<:item title="Longitude">{@beacon.lon}</:item>
<:item title="TX power (EIRP) (mW)">{@beacon.power_mw}</:item>
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
</.list>
</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(:estimate, RangeEstimate.estimate(beacon))}
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
end