Compact beacon detail page, toggleable coverage, About page formatting

- Beacon map defaults to just the marker at zoom 11. A daisyUI toggle
  switch shows/hides the estimated-coverage cell layer; the coverage
  info line and tier legend are hidden unless the toggle is on.
- Beacon detail list replaced with a compact dl grid (2-4 columns).
  Lat/lon rounded to 5 decimals, power/height/beamwidth formatted via
  Beacon.format_mw/1 so no more "2.5e3" scientific notation or "280.0".
  format_mw/1 hoisted onto the schema so index and show share it.
- About page no longer uses prose classes (this project doesn't load
  @tailwindcss/typography). Headings, lists, and paragraphs now use
  explicit utility classes so they render as intended.
This commit is contained in:
Graham McIntire 2026-04-09 09:12:21 -05:00
parent db39f94fc3
commit a95d2ada52
5 changed files with 264 additions and 179 deletions

View file

@ -6,23 +6,24 @@ export const BeaconMap = {
const onTheAir = this.el.dataset.onTheAir === "true" const onTheAir = this.el.dataset.onTheAir === "true"
const cells = JSON.parse(this.el.dataset.cells || "[]") const cells = JSON.parse(this.el.dataset.cells || "[]")
const step = parseFloat(this.el.dataset.gridStep || "0.125") const step = parseFloat(this.el.dataset.gridStep || "0.125")
const initialShowCoverage = this.el.dataset.showCoverage === "true"
const map = L.map(this.el, { const map = L.map(this.el, {
zoomControl: true, zoomControl: true,
attributionControl: false, attributionControl: false,
scrollWheelZoom: false scrollWheelZoom: false
}).setView([lat, lon], 9) }).setView([lat, lon], 11)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19 maxZoom: 19
}).addTo(map) }).addTo(map)
// Draw each HRRR grid cell as a filled rectangle colored by its received // Build the coverage cell layer up-front but keep it off the map until
// signal tier. Use a single layerGroup so zoom/pan stays fast. // the user toggles it on. Using a single layerGroup keeps pan/zoom fast.
const cellLayer = L.layerGroup().addTo(map) const cellLayer = L.layerGroup()
const half = step / 2.0 const half = step / 2.0
const allBounds = [] const allBounds = []
for (const c of cells) { for (const c of cells) {
const sw = [c.lat - half, c.lon - half] const sw = [c.lat - half, c.lon - half]
const ne = [c.lat + half, c.lon + half] const ne = [c.lat + half, c.lon + half]
@ -56,12 +57,28 @@ export const BeaconMap = {
offset: [0, -10] offset: [0, -10]
}) })
// Fit to the rendered cells (initial view already set above), then zoom const showLayer = () => {
// in two steps so the beacon's immediate surroundings are visible. if (!map.hasLayer(cellLayer)) cellLayer.addTo(map)
if (allBounds.length > 0) { if (allBounds.length > 0) {
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]}) map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
map.setZoom(map.getZoom() + 2) map.setZoom(map.getZoom() + 2)
} }
}
const hideLayer = () => {
if (map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
map.setView([lat, lon], 11)
}
if (initialShowCoverage) showLayer()
this.handleEvent("toggle_coverage", ({show}) => {
if (show) {
showLayer()
} else {
hideLayer()
}
})
setTimeout(() => map.invalidateSize(), 50) setTimeout(() => map.invalidateSize(), 50)
} }

View file

@ -45,6 +45,31 @@ defmodule Microwaveprop.Beacons.Beacon do
def keying_label(key), do: Map.get(@keying_labels, key, key) def keying_label(key), do: Map.get(@keying_labels, key, key)
@doc """
Formats a milliwatt power value as a plain decimal string, never scientific
notation. Integers are printed without a decimal; floats keep up to three
decimals with trailing zeros trimmed.
"""
def format_mw(nil), do: ""
def format_mw(mw) when is_float(mw) do
if mw == trunc(mw) do
Integer.to_string(trunc(mw))
else
mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
end
end
def format_mw(mw), do: to_string(mw)
defp trim_trailing_zeros(str) do
if String.contains?(str, ".") do
str |> String.trim_trailing("0") |> String.trim_trailing(".")
else
str
end
end
@doc """ @doc """
Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a
cleaner UI when dozens of weak-signal modes are present. cleaner UI when dozens of weak-signal modes are present.

View file

@ -21,8 +21,9 @@ defmodule MicrowavepropWeb.AboutLive do
<:subtitle>What we're building and why.</:subtitle> <:subtitle>What we're building and why.</:subtitle>
</.header> </.header>
<section class="prose prose-sm max-w-none dark:prose-invert"> <section class="space-y-8 text-sm leading-relaxed">
<h2>What we're trying to do</h2> <div class="space-y-3">
<h2 class="text-xl font-bold">What we're trying to do</h2>
<p> <p>
Microwave propagation above 10 GHz is dominated by the lower atmosphere: Microwave propagation above 10 GHz is dominated by the lower atmosphere:
humidity gradients, temperature inversions, ducting layers, rain cells, humidity gradients, temperature inversions, ducting layers, rain cells,
@ -31,17 +32,18 @@ defmodule MicrowavepropWeb.AboutLive do
window often lasts minutes, not hours. window often lasts minutes, not hours.
</p> </p>
<p> <p>
Traditionally microwave contacts are ruled by line of sight, but we've seen that is incorrect. This project is an attempt to build a data-driven Traditionally microwave contacts are ruled by line of sight, but we've
seen that is incorrect. This project is an attempt to build a data-driven
propagation prediction model specifically for the amateur microwave bands, propagation prediction model specifically for the amateur microwave bands,
using hourly numerical weather forecasts, historical contact records, and using hourly numerical weather forecasts, historical contact records, and
eventually calibrated beacon measurements. eventually calibrated beacon measurements.
</p> </p>
</div>
<h2>The approach</h2> <div class="space-y-3">
<p> <h2 class="text-xl font-bold">The approach</h2>
We pair two things that most propagation tools keep separate: <p>We pair two things that most propagation tools keep separate:</p>
</p> <ol class="list-decimal list-outside pl-5 space-y-2">
<ol>
<li> <li>
<strong>Atmospheric state</strong> hourly 3 km HRRR forecasts <strong>Atmospheric state</strong> hourly 3 km HRRR forecasts
(surface fields plus pressure-level profiles), hourly surface (surface fields plus pressure-level profiles), hourly surface
@ -69,11 +71,12 @@ defmodule MicrowavepropWeb.AboutLive do
formula is a hypothesis waiting to be tested against the contact formula is a hypothesis waiting to be tested against the contact
and beacon data. and beacon data.
</p> </p>
</div>
<h2>What we've collected</h2> <div class="space-y-3">
<h2 class="text-xl font-bold">What we've collected</h2>
<p>Live counts from the production database:</p> <p>Live counts from the production database:</p>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div class="not-prose my-4 grid grid-cols-2 md:grid-cols-3 gap-3">
<.stat_card label="Contacts" value={@stats.contacts} /> <.stat_card label="Contacts" value={@stats.contacts} />
<.stat_card label="Weather stations" value={@stats.weather_stations} /> <.stat_card label="Weather stations" value={@stats.weather_stations} />
<.stat_card label="Surface observations" value={@stats.surface_observations} /> <.stat_card label="Surface observations" value={@stats.surface_observations} />
@ -85,9 +88,11 @@ defmodule MicrowavepropWeb.AboutLive do
<.stat_card label="Beacons" value={@stats.beacons} /> <.stat_card label="Beacons" value={@stats.beacons} />
<.stat_card label="Commercial link samples" value={@stats.commercial_samples} /> <.stat_card label="Commercial link samples" value={@stats.commercial_samples} />
</div> </div>
</div>
<h2>The stack</h2> <div class="space-y-3">
<ul> <h2 class="text-xl font-bold">The stack</h2>
<ul class="list-disc list-outside pl-5 space-y-2">
<li> <li>
<strong>Backend:</strong> Elixir / Phoenix 1.8 with LiveView for the <strong>Backend:</strong> Elixir / Phoenix 1.8 with LiveView for the
real-time map, Ecto on PostgreSQL for storage, Oban for the real-time map, Ecto on PostgreSQL for storage, Oban for the
@ -122,9 +127,11 @@ defmodule MicrowavepropWeb.AboutLive do
enrichment. enrichment.
</li> </li>
</ul> </ul>
</div>
<h2>Where this goes next</h2> <div class="space-y-3">
<ul> <h2 class="text-xl font-bold">Where this goes next</h2>
<ul class="list-disc list-outside pl-5 space-y-2">
<li> <li>
<strong>Beacon calibration.</strong> A distributed network of <strong>Beacon calibration.</strong> A distributed network of
amateur receivers continuously reporting CW beacon signal levels, amateur receivers continuously reporting CW beacon signal levels,
@ -147,9 +154,12 @@ defmodule MicrowavepropWeb.AboutLive do
analysis blending, GOES total precipitable water. analysis blending, GOES total precipitable water.
</li> </li>
</ul> </ul>
</div>
<p class="text-xs opacity-60 mt-8"> <p class="text-xs opacity-60 pt-4 border-t border-base-300">
Built by and for the <a href="https://www.ntms.org" target="_blank">North Texas Microwave Society</a>. Built by and for the <a href="https://www.ntms.org" target="_blank" class="link link-hover">
North Texas Microwave Society
</a>.
</p> </p>
</section> </section>
</Layouts.app> </Layouts.app>

View file

@ -29,7 +29,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col> <:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="Lat">{beacon.lat}</:col> <:col :let={{_id, beacon}} label="Lat">{beacon.lat}</:col>
<:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col> <:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)}</:col> <:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col> <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)}</:col> <:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)}</:col>
<:col :let={{_id, beacon}} label="On air"> <:col :let={{_id, beacon}} label="On air">
@ -68,7 +68,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
<:col :let={{_id, beacon}} label="Frequency (MHz)">{beacon.frequency_mhz}</:col> <:col :let={{_id, beacon}} label="Frequency (MHz)">{beacon.frequency_mhz}</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col> <:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col> <:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)}</:col> <:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col> <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Submitted"> <:col :let={{_id, beacon}} label="Submitted">
{Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")} {Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")}
@ -162,26 +162,4 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
defp admin?(%{user: %{is_admin: true}}), do: true defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false defp admin?(_), do: false
# Format a milliwatt power value without scientific notation.
# Keeps up to 3 decimal places, drops trailing zeros.
defp format_mw(nil), do: ""
defp format_mw(mw) when is_float(mw) do
if mw == trunc(mw) do
Integer.to_string(trunc(mw))
else
mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
end
end
defp format_mw(mw), do: to_string(mw)
defp trim_trailing_zeros(str) do
if String.contains?(str, ".") do
str |> String.trim_trailing("0") |> String.trim_trailing(".")
else
str
end
end
end end

View file

@ -50,10 +50,23 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
data-on-the-air={to_string(@beacon.on_the_air)} data-on-the-air={to_string(@beacon.on_the_air)}
data-grid-step={@estimate.grid_step} data-grid-step={@estimate.grid_step}
data-cells={Jason.encode!(@estimate.cells)} data-cells={Jason.encode!(@estimate.cells)}
data-show-coverage={to_string(@show_coverage)}
> >
</div> </div>
<div class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3"> <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> <span>
Band <strong>{@estimate.band_label}</strong> &middot; Band <strong>{@estimate.band_label}</strong> &middot;
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> &middot; EIRP <strong>{@estimate.eirp_dbm} dBm</strong> &middot;
@ -71,7 +84,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
</span> </span>
</div> </div>
<div class="flex flex-wrap gap-3 text-xs mb-4"> <div :if={@show_coverage} class="flex flex-wrap gap-3 text-xs mb-4">
<%= for tier <- @estimate.tiers do %> <%= for tier <- @estimate.tiers do %>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span <span
@ -86,24 +99,39 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<% end %> <% end %>
</div> </div>
<.list> <div class="rounded-lg border border-base-300 bg-base-200/40 p-3 text-sm">
<:item title="Frequency (MHz)">{@beacon.frequency_mhz}</:item> <dl class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-4 gap-y-2">
<:item title="Callsign">{@beacon.callsign}</:item> <.stat_field label="Frequency">{@beacon.frequency_mhz} MHz</.stat_field>
<:item title="Grid">{@beacon.grid}</:item> <.stat_field label="Callsign">{@beacon.callsign}</.stat_field>
<:item title="Latitude">{@beacon.lat}</:item> <.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)}</.stat_field>
<:item title="Longitude">{@beacon.lon}</:item> <.stat_field label="On the air">
<:item title="TX power (EIRP) (mW)">{@beacon.power_mw}</:item> <span class={[
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item> "badge badge-sm",
<:item title="Bearing">{bearing_label(@beacon.bearing)}</:item> (@beacon.on_the_air && "badge-success") || "badge-ghost"
<:item :if={@beacon.beamwidth_deg} title="Beamwidth"> ]}>
{@beacon.beamwidth_deg}° {if @beacon.on_the_air, do: "Yes", else: "No"}
</:item> </span>
<:item title="Keying">{Beacon.keying_label(@beacon.keying)}</:item> </.stat_field>
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
<:item :if={@beacon.notes && @beacon.notes != ""} title="Notes"> <.stat_field label="Grid">{@beacon.grid}</.stat_field>
<div class="whitespace-pre-wrap">{@beacon.notes}</div> <.stat_field label="Latitude">{format_coord(@beacon.lat)}</.stat_field>
</:item> <.stat_field label="Longitude">{format_coord(@beacon.lon)}</.stat_field>
</.list> <.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW</.stat_field>
<.stat_field label="Height AGL">{Beacon.format_mw(@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> </Layouts.app>
""" """
end end
@ -118,10 +146,20 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
socket socket
|> assign(:page_title, "Beacon") |> assign(:page_title, "Beacon")
|> assign(:beacon, beacon) |> assign(:beacon, beacon)
|> assign(:show_coverage, false)
|> assign(:estimate, RangeEstimate.estimate(beacon))} |> assign(:estimate, RangeEstimate.estimate(beacon))}
end end
@impl true @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 def handle_event("approve", _params, socket) do
if admin?(socket.assigns.current_scope) do if admin?(socket.assigns.current_scope) do
{:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon) {:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon)
@ -160,4 +198,21 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
defp bearing_label(nil), do: "Omni" defp bearing_label(nil), do: "Omni"
defp bearing_label("omni"), do: "Omni" defp bearing_label("omni"), do: "Omni"
defp bearing_label(value), do: "#{value}°" defp bearing_label(value), do: "#{value}°"
defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 5)
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 end