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,13 +57,29 @@ 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,135 +21,145 @@ 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">
<p> <h2 class="text-xl font-bold">What we're trying to do</h2>
Microwave propagation above 10 GHz is dominated by the lower atmosphere: <p>
humidity gradients, temperature inversions, ducting layers, rain cells, Microwave propagation above 10 GHz is dominated by the lower atmosphere:
and hyper-local refractivity structure. At 10, 24, 47, 76, 122, and 241 GHz humidity gradients, temperature inversions, ducting layers, rain cells,
these effects decide whether a contact happens at 50 km or 500 km and the and hyper-local refractivity structure. At 10, 24, 47, 76, 122, and 241 GHz
window often lasts minutes, not hours. these effects decide whether a contact happens at 50 km or 500 km and the
</p> window often lasts minutes, not hours.
<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 <p>
propagation prediction model specifically for the amateur microwave bands, Traditionally microwave contacts are ruled by line of sight, but we've
using hourly numerical weather forecasts, historical contact records, and seen that is incorrect. This project is an attempt to build a data-driven
eventually calibrated beacon measurements. propagation prediction model specifically for the amateur microwave bands,
</p> using hourly numerical weather forecasts, historical contact records, and
eventually calibrated beacon measurements.
<h2>The approach</h2> </p>
<p>
We pair two things that most propagation tools keep separate:
</p>
<ol>
<li>
<strong>Atmospheric state</strong> hourly 3 km HRRR forecasts
(surface fields plus pressure-level profiles), hourly surface
observations from ASOS, 12-hourly upper-air soundings, and gridded
IEMRE reanalysis. From these we derive refractivity profiles,
ducting detection, minimum refractivity gradient, precipitable water,
boundary-layer depth, and a 9-factor composite score for every
0.125° cell on a CONUS grid, for each hour of an 18-hour forecast.
</li>
<li>
<strong>Historical contacts</strong> 58k+ amateur microwave QSOs
tagged with the atmospheric conditions at both ends and along the
path at the exact time they happened. That gives us a ground-truth
dataset we can use to calibrate the scoring weights and, eventually,
train a machine learning model that predicts contact success given
conditions.
</li>
</ol>
<p>
The current scoring algorithm is a weighted sum of nine factors
(humidity, time of day, TTd depression, refractivity gradient, sky
cover, season, rain, wind, pressure) with band-dependent weights
humidity helps at 10 GHz via enhanced refractivity but hurts at
24+ GHz via absorption, for example. Every coefficient in that
formula is a hypothesis waiting to be tested against the contact
and beacon data.
</p>
<h2>What we've collected</h2>
<p>Live counts from the production database:</p>
<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="Weather stations" value={@stats.weather_stations} />
<.stat_card label="Surface observations" value={@stats.surface_observations} />
<.stat_card label="Upper-air soundings" value={@stats.soundings} />
<.stat_card label="HRRR profiles" value={@stats.hrrr_profiles} />
<.stat_card label="IEMRE gridded obs" value={@stats.iemre_observations} />
<.stat_card label="Terrain profiles" value={@stats.terrain_profiles} />
<.stat_card label="Propagation scores" value={@stats.propagation_scores} />
<.stat_card label="Beacons" value={@stats.beacons} />
<.stat_card label="Commercial link samples" value={@stats.commercial_samples} />
</div> </div>
<h2>The stack</h2> <div class="space-y-3">
<ul> <h2 class="text-xl font-bold">The approach</h2>
<li> <p>We pair two things that most propagation tools keep separate:</p>
<strong>Backend:</strong> Elixir / Phoenix 1.8 with LiveView for the <ol class="list-decimal list-outside pl-5 space-y-2">
real-time map, Ecto on PostgreSQL for storage, Oban for the <li>
background data pipelines (HRRR fetch, terrain, ASOS, soundings, <strong>Atmospheric state</strong> hourly 3 km HRRR forecasts
IEMRE, solar indices, commercial links), Bandit as the HTTP server. (surface fields plus pressure-level profiles), hourly surface
</li> observations from ASOS, 12-hourly upper-air soundings, and gridded
<li> IEMRE reanalysis. From these we derive refractivity profiles,
<strong>Frontend:</strong> LiveView with Leaflet for maps, Canvas ducting detection, minimum refractivity gradient, precipitable water,
tile layers for the HRRR heatmap (we render 0.125° cells at boundary-layer depth, and a 9-factor composite score for every
interactive frame rates), Tailwind v4 + daisyUI for layout, esbuild 0.125° cell on a CONUS grid, for each hour of an 18-hour forecast.
for JS bundling. </li>
</li> <li>
<li> <strong>Historical contacts</strong> 58k+ amateur microwave QSOs
<strong>Physics:</strong> ITU-R P.526-16 knife-edge + Deygout tagged with the atmospheric conditions at both ends and along the
3-edge terrain diffraction, ITU-R P.838-3 rain attenuation, path at the exact time they happened. That gives us a ground-truth
dynamic k-factor from live HRRR refractivity gradients, great-circle dataset we can use to calibrate the scoring weights and, eventually,
geometry for link budgets, Free-Space Path Loss with frequency- train a machine learning model that predicts contact success given
dependent O and HO absorption per band. conditions.
</li> </li>
<li> </ol>
<strong>Machine learning:</strong> Nx / Axon / EXLA scaffolding for <p>
a 13-feature (8 atmospheric + 4 cyclical temporal + 1 log-frequency) The current scoring algorithm is a weighted sum of nine factors
feed-forward network, 64 32 1 sigmoid. Not trained yet (humidity, time of day, TTd depression, refractivity gradient, sky
waiting on a larger calibration dataset. cover, season, rain, wind, pressure) with band-dependent weights
</li> humidity helps at 10 GHz via enhanced refractivity but hurts at
<li> 24+ GHz via absorption, for example. Every coefficient in that
<strong>Data sources:</strong> NOAA HRRR model (AWS S3, hourly formula is a hypothesis waiting to be tested against the contact
analysis + 18 h forecasts), Iowa Environmental Mesonet (ASOS &amp; and beacon data.
upper-air soundings), IEMRE gridded reanalysis, SRTM 90 m terrain </p>
tiles, SNMP polling of seven commercial microwave links near DFW </div>
at 5-minute intervals, Copernicus ERA5 for pre-2014 contact
enrichment.
</li>
</ul>
<h2>Where this goes next</h2> <div class="space-y-3">
<ul> <h2 class="text-xl font-bold">What we've collected</h2>
<li> <p>Live counts from the production database:</p>
<strong>Beacon calibration.</strong> A distributed network of <div class="grid grid-cols-2 md:grid-cols-3 gap-3">
amateur receivers continuously reporting CW beacon signal levels, <.stat_card label="Contacts" value={@stats.contacts} />
feeding ground-truth measurements back into the scoring algorithm. <.stat_card label="Weather stations" value={@stats.weather_stations} />
Beacon submissions are now open to anyone via the Beacons page. <.stat_card label="Surface observations" value={@stats.surface_observations} />
</li> <.stat_card label="Upper-air soundings" value={@stats.soundings} />
<li> <.stat_card label="HRRR profiles" value={@stats.hrrr_profiles} />
<strong>Scoring weight calibration.</strong> Fit the 9-factor <.stat_card label="IEMRE gridded obs" value={@stats.iemre_observations} />
weights against recorded QSO distances / counts per band so the <.stat_card label="Terrain profiles" value={@stats.terrain_profiles} />
composite score reflects real propagation rather than intuition. <.stat_card label="Propagation scores" value={@stats.propagation_scores} />
</li> <.stat_card label="Beacons" value={@stats.beacons} />
<li> <.stat_card label="Commercial link samples" value={@stats.commercial_samples} />
<strong>ML model training.</strong> Once we have enough labeled </div>
contact+condition pairs, train the Axon model to replace or augment </div>
the hand-tuned scoring function.
</li>
<li>
<strong>Better inputs.</strong> MRMS for precipitation at
24+ GHz (where rain attenuation dominates), RTMA/URMA surface
analysis blending, GOES total precipitable water.
</li>
</ul>
<p class="text-xs opacity-60 mt-8"> <div class="space-y-3">
Built by and for the <a href="https://www.ntms.org" target="_blank">North Texas Microwave Society</a>. <h2 class="text-xl font-bold">The stack</h2>
<ul class="list-disc list-outside pl-5 space-y-2">
<li>
<strong>Backend:</strong> Elixir / Phoenix 1.8 with LiveView for the
real-time map, Ecto on PostgreSQL for storage, Oban for the
background data pipelines (HRRR fetch, terrain, ASOS, soundings,
IEMRE, solar indices, commercial links), Bandit as the HTTP server.
</li>
<li>
<strong>Frontend:</strong> LiveView with Leaflet for maps, Canvas
tile layers for the HRRR heatmap (we render 0.125° cells at
interactive frame rates), Tailwind v4 + daisyUI for layout, esbuild
for JS bundling.
</li>
<li>
<strong>Physics:</strong> ITU-R P.526-16 knife-edge + Deygout
3-edge terrain diffraction, ITU-R P.838-3 rain attenuation,
dynamic k-factor from live HRRR refractivity gradients, great-circle
geometry for link budgets, Free-Space Path Loss with frequency-
dependent O and HO absorption per band.
</li>
<li>
<strong>Machine learning:</strong> Nx / Axon / EXLA scaffolding for
a 13-feature (8 atmospheric + 4 cyclical temporal + 1 log-frequency)
feed-forward network, 64 32 1 sigmoid. Not trained yet
waiting on a larger calibration dataset.
</li>
<li>
<strong>Data sources:</strong> NOAA HRRR model (AWS S3, hourly
analysis + 18 h forecasts), Iowa Environmental Mesonet (ASOS &amp;
upper-air soundings), IEMRE gridded reanalysis, SRTM 90 m terrain
tiles, SNMP polling of seven commercial microwave links near DFW
at 5-minute intervals, Copernicus ERA5 for pre-2014 contact
enrichment.
</li>
</ul>
</div>
<div class="space-y-3">
<h2 class="text-xl font-bold">Where this goes next</h2>
<ul class="list-disc list-outside pl-5 space-y-2">
<li>
<strong>Beacon calibration.</strong> A distributed network of
amateur receivers continuously reporting CW beacon signal levels,
feeding ground-truth measurements back into the scoring algorithm.
Beacon submissions are now open to anyone via the Beacons page.
</li>
<li>
<strong>Scoring weight calibration.</strong> Fit the 9-factor
weights against recorded QSO distances / counts per band so the
composite score reflects real propagation rather than intuition.
</li>
<li>
<strong>ML model training.</strong> Once we have enough labeled
contact+condition pairs, train the Axon model to replace or augment
the hand-tuned scoring function.
</li>
<li>
<strong>Better inputs.</strong> MRMS for precipitation at
24+ GHz (where rain attenuation dominates), RTMA/URMA surface
analysis blending, GOES total precipitable water.
</li>
</ul>
</div>
<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" 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