prop/lib/microwaveprop_web/live/beacon_live/show.ex
Graham McIntire a5b3f1f3da Beacon detail map with range estimate and on_the_air flag
- Add on_the_air boolean to beacons (default true); surfaced as a
  checkbox on the form, a badge on the index, and in the detail list.
- Render a Leaflet map on the beacon show page with a marker at the
  beacon's lat/lon tooltipped with callsign + frequency. Marker is
  green when on air, gray when off.
- Compute and draw a reception-range estimate as concentric signal-
  strength rings. New Microwaveprop.Beacons.RangeEstimate solves a
  link budget (FSPL + O2/H2O absorption from BandConfig) at five RX
  thresholds (-100 to -145 dBm), then scales by 0.5 + score/100 from
  the latest Propagation.point_detail at the beacon's grid square, so
  current HRRR conditions shift the rings in or out.
- Re-enable the hourly PropagationGridWorker cron and freshness
  monitor in dev.exs so dev actually has HRRR-backed scores to feed
  the new estimator.
2026-04-08 13:29:58 -05:00

124 lines
4 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-rings={Jason.encode!(@estimate.rings)}
>
</div>
<div class="text-xs opacity-70 mb-4 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>
Prop score <strong>{@estimate.score}</strong>
(<strong>{@estimate.score_mult}×</strong> range)
<%= if @estimate.valid_time do %>
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
(no HRRR data — using default 50)
<% end %>
</span>
</div>
<div class="flex flex-wrap gap-3 text-xs mb-4">
<%= for ring <- @estimate.rings do %>
<div class="flex items-center gap-1.5">
<span
class="inline-block w-3 h-3 rounded-full border"
style={"background-color: #{ring.color}; border-color: #{ring.color}"}
>
</span>
<span>
<strong>{ring.label}</strong>
({ring.rx_dbm} dBm) &rarr; {ring.radius_km} km
</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="Power (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