- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg) - Move 12+ nested alias/import/require to module top level - Add phx-change/id attributes to 11 raw HTML <form> tags - Remove 4 unused LiveView assigns (:bounds, :data_provider) - Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts) - Break 2 long lines (path_compute.ex:382) - Strengthen weak test assertions (is_binary→byte_size, is_list→!=[]) - Replace Module.concat with Module.safe_concat (2 occurrences) - Replace length/1 > 0 with list != [] (9 occurrences) - Remove no-op assert true, fix no-assertion tests Remaining: 24 socket.assigns introspection warnings (deliberate test pattern for observable behavior testing), 1 formatter-resistant long line, 3 app-code usage warnings.
567 lines
21 KiB
Elixir
567 lines
21 KiB
Elixir
defmodule MicrowavepropWeb.EmeLive do
|
||
@moduledoc """
|
||
`/eme` Earth-Moon-Earth path-loss + antenna-aim calculator.
|
||
|
||
Given a station's grid/callsign/coords, TX power, and antenna gain,
|
||
the page computes:
|
||
|
||
- Current Moon az / el from the station (real-time tick).
|
||
- Slant range to the Moon.
|
||
- Round-trip EME path loss at the operating band.
|
||
- Link-budget received power for a self-echo bounce.
|
||
- Indicative CW SNR in a narrow detection bandwidth.
|
||
|
||
The Moon readout ticks once per 10 s so operators can watch the
|
||
aim drift live. Moon az/el is computed with a low-precision
|
||
ephemeris (±0.3°) — well inside the beamwidth of any amateur EME
|
||
dish at the supported microwave bands.
|
||
"""
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
|
||
|
||
alias Microwaveprop.Moon
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
alias Microwaveprop.Propagation.Eme
|
||
|
||
@band_options BandConfig.band_options()
|
||
@tick_ms 10_000
|
||
|
||
@defaults %{
|
||
"source" => "",
|
||
"band" => "1296",
|
||
"tx_power_dbm" => "50",
|
||
"tx_gain_dbi" => "30",
|
||
"bandwidth_hz" => "100",
|
||
"t_sys_k" => "150"
|
||
}
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
if connected?(socket) do
|
||
{:ok, _tref} = :timer.send_interval(@tick_ms, self(), :tick)
|
||
:ok
|
||
else
|
||
:ok
|
||
end
|
||
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "EME Calculator",
|
||
band_options: @band_options,
|
||
source: "",
|
||
source_resolved: nil,
|
||
band: "1296",
|
||
tx_power_dbm: "50",
|
||
tx_gain_dbi: "30",
|
||
bandwidth_hz: "100",
|
||
t_sys_k: "150",
|
||
moon: nil,
|
||
link: nil,
|
||
error: nil,
|
||
now_utc: DateTime.utc_now()
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_params(params, _uri, socket) do
|
||
p = Map.merge(@defaults, Map.take(params, Map.keys(@defaults)))
|
||
|
||
socket =
|
||
assign(socket,
|
||
source: p["source"],
|
||
band: p["band"],
|
||
tx_power_dbm: p["tx_power_dbm"],
|
||
tx_gain_dbi: p["tx_gain_dbi"],
|
||
bandwidth_hz: p["bandwidth_hz"],
|
||
t_sys_k: p["t_sys_k"]
|
||
)
|
||
|
||
{:noreply, recompute(socket)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("calculate", params, socket) do
|
||
url_params =
|
||
@defaults
|
||
|> Map.keys()
|
||
|> Enum.reduce(%{}, fn k, acc ->
|
||
v = params[k] || ""
|
||
if v == "" or v == @defaults[k], do: acc, else: Map.put(acc, k, v)
|
||
end)
|
||
|
||
{:noreply, push_patch(socket, to: ~p"/eme?#{url_params}", replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info(:tick, socket) do
|
||
{:noreply, socket |> assign(now_utc: DateTime.utc_now()) |> recompute_moon_only()}
|
||
end
|
||
|
||
# Recompute both the source resolution and the link budget (called on
|
||
# form submit / URL patch).
|
||
defp recompute(socket) do
|
||
case resolve_source(socket.assigns.source) do
|
||
{:ok, resolved} ->
|
||
socket
|
||
|> assign(source_resolved: resolved, error: nil)
|
||
|> recompute_moon_only()
|
||
|
||
{:error, reason} ->
|
||
assign(socket, source_resolved: nil, error: reason, moon: nil, link: nil)
|
||
|
||
:empty ->
|
||
assign(socket, source_resolved: nil, error: nil, moon: nil, link: nil)
|
||
end
|
||
end
|
||
|
||
# Refresh just the live-ticking numbers (moon pos + link budget).
|
||
# Called from the tick timer so a stale error state doesn't get
|
||
# stomped by a repeat source-resolution.
|
||
defp recompute_moon_only(%{assigns: %{source_resolved: nil}} = socket), do: socket
|
||
|
||
defp recompute_moon_only(socket) do
|
||
%{lat: lat, lon: lon} = socket.assigns.source_resolved
|
||
moon = Moon.observer_position(socket.assigns.now_utc, lat, lon)
|
||
|
||
band_mhz = parse_int(socket.assigns.band, 1_296)
|
||
tx_dbm = parse_float(socket.assigns.tx_power_dbm, 50.0)
|
||
tx_gain = parse_float(socket.assigns.tx_gain_dbi, 30.0)
|
||
bw = parse_float(socket.assigns.bandwidth_hz, 100.0)
|
||
tsys = parse_float(socket.assigns.t_sys_k, 150.0)
|
||
|
||
# Band-dependent lunar albedo feeds both the path-loss number
|
||
# and the per-line-item display below.
|
||
albedo = Eme.moon_albedo(band_mhz)
|
||
|
||
fspl_rt = Eme.fspl_round_trip_db(band_mhz, moon.distance_km)
|
||
moon_gain = Eme.moon_reflection_gain_db(band_mhz, albedo)
|
||
path_loss = fspl_rt - moon_gain
|
||
|
||
# Keep everything in dBm for display — EIRP(dBm) = TX(dBm) + G_tx(dBi).
|
||
eirp_dbm = tx_dbm + tx_gain
|
||
|
||
received_power = tx_dbm + tx_gain + tx_gain - path_loss
|
||
noise_floor = Eme.noise_floor_dbm(bw, tsys)
|
||
snr = received_power - noise_floor
|
||
|
||
# Self-echo Doppler (Earth → Moon → Earth) for the current
|
||
# geometry. Live-updates with the tick.
|
||
v_radial_km_s = Moon.radial_velocity_km_s(socket.assigns.now_utc, lat, lon)
|
||
doppler_hz = Eme.doppler_shift_hz(band_mhz, v_radial_km_s)
|
||
|
||
link = %{
|
||
# EIRP breakdown so the template can render "TX + gain = EIRP".
|
||
tx_power_dbm: tx_dbm,
|
||
tx_power_w: :math.pow(10.0, (tx_dbm - 30) / 10.0),
|
||
tx_gain_dbi: tx_gain,
|
||
eirp_dbm: eirp_dbm,
|
||
eirp_w: :math.pow(10.0, (eirp_dbm - 30) / 10.0),
|
||
|
||
# Path-loss decomposition: spreading vs moon-reflection gain.
|
||
fspl_round_trip_db: fspl_rt,
|
||
moon_albedo: albedo,
|
||
moon_reflection_gain_db: moon_gain,
|
||
path_loss_db: path_loss,
|
||
|
||
# Final link numbers.
|
||
received_power_dbm: received_power,
|
||
noise_floor_dbm: noise_floor,
|
||
snr_db: snr,
|
||
|
||
# Doppler (2× one-way for self-echo round-trip).
|
||
radial_velocity_km_s: v_radial_km_s,
|
||
doppler_hz: doppler_hz
|
||
}
|
||
|
||
socket
|
||
|> assign(moon: moon, link: link)
|
||
|> push_event("eme:update", %{
|
||
lat: lat,
|
||
lon: lon,
|
||
az: moon.azimuth,
|
||
el: moon.elevation,
|
||
moon_visible: moon.elevation >= 0.0
|
||
})
|
||
end
|
||
|
||
# --- Input parsing --------------------------------------------------
|
||
|
||
defp resolve_source(input), do: MicrowavepropWeb.LocationResolver.resolve(input)
|
||
|
||
# --- Render helpers -------------------------------------------------
|
||
#
|
||
# Note on UTF-8: these use Elixir's native :erlang.float_to_binary/2
|
||
# rather than :io_lib.format/2 because the latter treats each byte of
|
||
# an inline UTF-8 codepoint (like "°" = 0xC2 0xB0) as a separate
|
||
# Latin-1 character, producing mojibake (°) when the result is
|
||
# joined back into a binary.
|
||
|
||
defp format_deg(nil), do: "—"
|
||
|
||
defp format_deg(v) when is_number(v) do
|
||
:erlang.float_to_binary(v * 1.0, decimals: 2) <> "°"
|
||
end
|
||
|
||
defp format_distance_km(nil), do: "—"
|
||
|
||
defp format_distance_km(v) when is_number(v) do
|
||
# Integer km with thousands separators ("374,088 km").
|
||
v |> round() |> Microwaveprop.Format.number() |> Kernel.<>(" km")
|
||
end
|
||
|
||
defp format_db(nil), do: "—"
|
||
defp format_db(v) when is_number(v), do: :erlang.float_to_binary(v * 1.0, decimals: 1) <> " dB"
|
||
|
||
defp format_dbm(nil), do: "—"
|
||
|
||
defp format_dbm(v) when is_number(v) do
|
||
:erlang.float_to_binary(v * 1.0, decimals: 1) <> " dBm"
|
||
end
|
||
|
||
defp format_doppler(nil), do: "—"
|
||
|
||
defp format_doppler(hz) when is_number(hz) do
|
||
abs_hz = abs(hz)
|
||
|
||
body =
|
||
cond do
|
||
abs_hz >= 1000.0 -> :erlang.float_to_binary(hz / 1000.0, decimals: 2) <> " kHz"
|
||
abs_hz >= 1.0 -> :erlang.float_to_binary(hz * 1.0, decimals: 0) <> " Hz"
|
||
true -> :erlang.float_to_binary(hz * 1.0, decimals: 2) <> " Hz"
|
||
end
|
||
|
||
# Prepend a + sign for positive (rising) shifts; `float_to_binary`
|
||
# already renders negatives with the leading − character.
|
||
if hz > 0, do: "+" <> body, else: body
|
||
end
|
||
|
||
defp format_radial_velocity(nil), do: "—"
|
||
|
||
defp format_radial_velocity(v_km_s) when is_number(v_km_s) do
|
||
# Display in m/s at the ~kHz-scale Doppler levels EME operators
|
||
# care about. Positive = receding.
|
||
m_s = v_km_s * 1000.0
|
||
suffix = " m/s"
|
||
body = :erlang.float_to_binary(m_s, decimals: 1)
|
||
if m_s > 0, do: "+" <> body <> suffix, else: body <> suffix
|
||
end
|
||
|
||
defp format_watts(nil), do: "—"
|
||
|
||
defp format_watts(w) when is_number(w) and w >= 1000 do
|
||
:erlang.float_to_binary(w / 1000.0, decimals: 2) <> " kW"
|
||
end
|
||
|
||
defp format_watts(w) when is_number(w) and w >= 1 do
|
||
:erlang.float_to_binary(w * 1.0, decimals: 1) <> " W"
|
||
end
|
||
|
||
defp format_watts(w) when is_number(w) and w > 0 do
|
||
:erlang.float_to_binary(w * 1000.0, decimals: 2) <> " mW"
|
||
end
|
||
|
||
defp format_watts(_), do: "—"
|
||
|
||
defp format_albedo(nil), do: "—"
|
||
|
||
defp format_albedo(v) when is_number(v) do
|
||
:erlang.float_to_binary(v * 1.0, decimals: 3)
|
||
end
|
||
|
||
defp snr_badge_class(nil), do: "badge-ghost"
|
||
defp snr_badge_class(snr) when snr >= 10, do: "badge-success"
|
||
defp snr_badge_class(snr) when snr >= 0, do: "badge-warning"
|
||
defp snr_badge_class(_), do: "badge-error"
|
||
|
||
defp elevation_badge_class(nil), do: "badge-ghost"
|
||
defp elevation_badge_class(el) when el >= 5.0, do: "badge-success"
|
||
defp elevation_badge_class(el) when el >= 0.0, do: "badge-warning"
|
||
defp elevation_badge_class(_), do: "badge-error"
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||
<div class="mx-auto max-w-5xl px-4 py-6 space-y-6">
|
||
<header>
|
||
<h1 class="text-2xl font-semibold">EME Calculator</h1>
|
||
<p class="opacity-70 text-sm">
|
||
Earth-Moon-Earth path loss, link budget, and real-time Moon az / el for antenna aiming.
|
||
Moon position updates every 10 seconds; refresh to force an immediate recompute.
|
||
</p>
|
||
</header>
|
||
|
||
<form
|
||
phx-submit="calculate"
|
||
phx-change="calculate"
|
||
id="eme-form"
|
||
class="bg-base-200 rounded-box p-4 grid grid-cols-1 md:grid-cols-3 gap-3"
|
||
>
|
||
<label class="form-control md:col-span-3">
|
||
<div class="label">
|
||
<span class="label-text">Station (callsign, grid, or "lat,lon")</span>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
name="source"
|
||
value={@source}
|
||
placeholder="e.g. W5ISP / EM13ng / 32.9,-97.0"
|
||
class="input input-bordered"
|
||
autocomplete="off"
|
||
/>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<div class="label"><span class="label-text">Band (MHz)</span></div>
|
||
<select name="band" class="select select-bordered">
|
||
<%= for {label, value} <- @band_options do %>
|
||
<option value={value} selected={value == @band}>{label}</option>
|
||
<% end %>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<div class="label"><span class="label-text">TX power (dBm)</span></div>
|
||
<input
|
||
type="number"
|
||
name="tx_power_dbm"
|
||
value={@tx_power_dbm}
|
||
step="0.1"
|
||
class="input input-bordered"
|
||
/>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<div class="label"><span class="label-text">Antenna gain (dBi)</span></div>
|
||
<input
|
||
type="number"
|
||
name="tx_gain_dbi"
|
||
value={@tx_gain_dbi}
|
||
step="0.1"
|
||
class="input input-bordered"
|
||
/>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<div class="label"><span class="label-text">Detection BW (Hz)</span></div>
|
||
<input
|
||
type="number"
|
||
name="bandwidth_hz"
|
||
value={@bandwidth_hz}
|
||
step="1"
|
||
class="input input-bordered"
|
||
/>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<div class="label"><span class="label-text">System noise Tsys (K)</span></div>
|
||
<input
|
||
type="number"
|
||
name="t_sys_k"
|
||
value={@t_sys_k}
|
||
step="1"
|
||
class="input input-bordered"
|
||
/>
|
||
</label>
|
||
|
||
<div class="md:col-span-1 flex items-end">
|
||
<button type="submit" class="btn btn-primary w-full">Calculate</button>
|
||
</div>
|
||
</form>
|
||
|
||
<%= if @error do %>
|
||
<div class="alert alert-error">
|
||
<span>{@error}</span>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @source_resolved do %>
|
||
<section class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="bg-base-100 border border-base-300 rounded-box p-4">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<h2 class="text-base font-semibold">Antenna aim</h2>
|
||
<span class="text-xs opacity-60 font-mono whitespace-nowrap">
|
||
{Calendar.strftime(@now_utc, "%Y-%m-%d %H:%M:%SZ")}
|
||
</span>
|
||
</div>
|
||
<dl class="mt-3 text-sm space-y-1.5">
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">Station</dt>
|
||
<dd class="font-mono text-right">{@source_resolved.label}</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">Lat / lon</dt>
|
||
<dd class="font-mono text-right">
|
||
{Float.round(@source_resolved.lat, 3)}, {Float.round(@source_resolved.lon, 3)}
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">Azimuth</dt>
|
||
<dd class="font-mono text-right">{format_deg(@moon && @moon.azimuth)}</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3 flex-wrap">
|
||
<dt class="opacity-70 shrink-0">Elevation</dt>
|
||
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
|
||
<span class={"badge badge-sm whitespace-nowrap " <> elevation_badge_class(@moon && @moon.elevation)}>
|
||
<%= cond do %>
|
||
<% is_nil(@moon) -> %>
|
||
—
|
||
<% @moon.elevation >= 5.0 -> %>
|
||
above horizon
|
||
<% @moon.elevation >= 0.0 -> %>
|
||
low
|
||
<% true -> %>
|
||
below horizon
|
||
<% end %>
|
||
</span>
|
||
<span>{format_deg(@moon && @moon.elevation)}</span>
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">Slant range</dt>
|
||
<dd class="font-mono text-right">
|
||
{format_distance_km(@moon && @moon.distance_km)}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
<div class="bg-base-100 border border-base-300 rounded-box p-4">
|
||
<h2 class="text-base font-semibold">Link budget (Earth → Moon → Earth)</h2>
|
||
<dl class="mt-3 text-sm space-y-1.5">
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">TX power</dt>
|
||
<dd class="font-mono text-right">
|
||
{format_dbm(@link && @link.tx_power_dbm)}
|
||
<span class="opacity-60 text-xs">
|
||
({format_watts(@link && @link.tx_power_w)})
|
||
</span>
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">Antenna gain</dt>
|
||
<dd class="font-mono text-right">
|
||
+{@link && :erlang.float_to_binary(@link.tx_gain_dbi * 1.0, decimals: 1)} dBi
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3 border-t border-base-300 pt-1.5">
|
||
<dt class="opacity-70 shrink-0">= EIRP</dt>
|
||
<dd class="font-mono text-right font-semibold">
|
||
{format_dbm(@link && @link.eirp_dbm)}
|
||
<span class="opacity-60 text-xs">({format_watts(@link && @link.eirp_w)})</span>
|
||
</dd>
|
||
</div>
|
||
|
||
<div class="flex items-baseline justify-between gap-3 mt-2">
|
||
<dt class="opacity-70 shrink-0">Free-space spreading (×2)</dt>
|
||
<dd class="font-mono text-right">
|
||
{format_db(@link && @link.fspl_round_trip_db)}
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">
|
||
Moon reflection
|
||
<span class="text-xs opacity-70">
|
||
(ρ = {format_albedo(@link && @link.moon_albedo)})
|
||
</span>
|
||
</dt>
|
||
<dd class="font-mono text-right text-success">
|
||
−{format_db(@link && @link.moon_reflection_gain_db)}
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3 border-t border-base-300 pt-1.5">
|
||
<dt class="opacity-70 shrink-0">= Round-trip path loss</dt>
|
||
<dd class="font-mono text-right font-semibold">
|
||
{format_db(@link && @link.path_loss_db)}
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3 mt-2">
|
||
<dt class="opacity-70 shrink-0">Received power</dt>
|
||
<dd class="font-mono text-right">
|
||
{format_dbm(@link && @link.received_power_dbm)}
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">Noise floor</dt>
|
||
<dd class="font-mono text-right">{format_dbm(@link && @link.noise_floor_dbm)}</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<dt class="opacity-70 shrink-0">Doppler (self-echo)</dt>
|
||
<dd class="font-mono text-right">
|
||
{format_doppler(@link && @link.doppler_hz)}
|
||
<span class="opacity-60 text-xs">
|
||
({format_radial_velocity(@link && @link.radial_velocity_km_s)})
|
||
</span>
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-3 flex-wrap border-t border-base-300 pt-1.5">
|
||
<dt class="opacity-70 shrink-0">SNR</dt>
|
||
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
|
||
<span class={"badge badge-sm whitespace-nowrap " <> snr_badge_class(@link && @link.snr_db)}>
|
||
<%= cond do %>
|
||
<% is_nil(@link) -> %>
|
||
—
|
||
<% @link.snr_db >= 10 -> %>
|
||
strong
|
||
<% @link.snr_db >= 0 -> %>
|
||
marginal
|
||
<% true -> %>
|
||
below noise
|
||
<% end %>
|
||
</span>
|
||
<span class="font-semibold">{format_db(@link && @link.snr_db)}</span>
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<p class="text-xs opacity-60 mt-3">
|
||
Round-trip path loss = 2 × free-space spreading minus the Moon's
|
||
effective scattering gain <code>10·log(4π σ / λ²)</code>, with σ = ρ·π·R²<sub>moon</sub>.
|
||
Albedo ρ varies by band (≈ 0.065 at VHF, ≈ 0.08 at microwave, dropping above 40 GHz).
|
||
Cable, feed, and polarisation losses are on you.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<%= if @moon do %>
|
||
<section class="bg-base-100 border border-base-300 rounded-box p-4">
|
||
<div class="flex items-baseline justify-between gap-3 flex-wrap">
|
||
<h2 class="text-base font-semibold">Earth–Moon geometry (not to scale)</h2>
|
||
<span class="text-xs opacity-60">
|
||
Drag to rotate. Shaded hemisphere is everyone who cannot currently see the Moon — outside your bounce footprint.
|
||
</span>
|
||
</div>
|
||
<div
|
||
id="eme-globe"
|
||
phx-hook="EmeGlobe"
|
||
phx-update="ignore"
|
||
data-station-lat={Float.round(@source_resolved.lat, 4)}
|
||
data-station-lon={Float.round(@source_resolved.lon, 4)}
|
||
data-moon-az={Float.round(@moon.azimuth, 3)}
|
||
data-moon-el={Float.round(@moon.elevation, 3)}
|
||
data-moon-visible={to_string(@moon.elevation >= 0.0)}
|
||
class="mt-3 rounded-box overflow-hidden bg-[#050914]"
|
||
style="min-height: 360px;"
|
||
>
|
||
</div>
|
||
<%= if @moon.elevation < 0.0 do %>
|
||
<p class="text-xs text-warning mt-2">
|
||
Moon is below your horizon right now — no line-of-sight path. Wait for moonrise.
|
||
</p>
|
||
<% end %>
|
||
<p class="text-[10px] opacity-50 mt-2">
|
||
Earth texture: NASA Blue Marble 2015 (public domain). Moon texture: NASA CGI Moon Kit, LROC colour mosaic (public domain).
|
||
</p>
|
||
</section>
|
||
<% end %>
|
||
<% else %>
|
||
<div class="text-sm opacity-70">
|
||
Enter your callsign or grid above to start the live Moon aim readout.
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
</Layouts.app>
|
||
"""
|
||
end
|
||
end
|