prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire 6f452e7139
feat(map): show CONUS-only banner for visitors outside continental US
Visitor geolocation already flows via the Cloudflare CF-IPLatitude /
CF-IPLongitude headers into the session; MapLive centers the map on
the visitor's location (or DFW as fallback). Add a banner that appears
above the map when those coords are outside the CONUS bbox
(lat 24.5-49.5, lon -125 to -66.5), letting overseas visitors know
propagation data won't be available at their location yet.

Map still centers on the visitor's actual location — this is a note,
not a re-center. Banner is fully absent from the DOM when not needed
(:if conditional).
2026-04-17 08:39:51 -05:00

890 lines
31 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.MapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveStash
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.PipelineStatus
alias Microwaveprop.Terrain.Viewshed
require Logger
@default_band 10_000
# Default map center when no visitor geolocation is available — DFW (EM12).
@default_center %{lat: 32.897, lon: -97.038}
@default_zoom 7
# Continental-US bounding box (excludes Alaska, Hawaii, Puerto Rico). Used
# to decide whether to show the "data is CONUS-only" banner to visitors
# whose Cloudflare geolocation falls outside the dataset's coverage.
@conus_lat_min 24.5
@conus_lat_max 49.5
@conus_lon_min -125.0
@conus_lon_max -66.5
# How often to re-query oban_jobs for pipeline state while the map is
# open. Short enough that the "Updating…" chip shows up within a page-
# visit for an in-progress hourly run, long enough to be cheap.
@pipeline_status_refresh_ms 15_000
@impl true
def mount(_params, session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
end
socket =
socket
|> assign(:initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
|> assign(:pipeline_status, PipelineStatus.current())
|> assign(:pipeline_progress, nil)
# LiveStash only persists the keys passed to `stash_assigns/2`
# (selected_band + selected_time). On reconnect the recovered socket has
# just those — the rest of the mount-time assigns (bands, bounds, the
# initial score JSON payload, toggles, etc.) have to be rebuilt or the
# first render crashes with KeyError on :initial_scores_json.
{recovered_band, recovered_time, socket} =
case LiveStash.recover_state(socket) do
{:recovered, recovered} ->
{recovered.assigns[:selected_band], recovered.assigns[:selected_time], recovered}
_ ->
{nil, nil, socket}
end
bands = BandConfig.all_bands()
selected_band = recovered_band || @default_band
valid_times = Propagation.available_valid_times(selected_band)
selected_time = recovered_time || closest_to_now(valid_times)
center = initial_center(session)
visitor = visitor_location(session)
bounds = bounds_around(center)
initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)
# preload_forecast runs on the first map_bounds event instead of
# here — mount only knows a hardcoded fallback bounding box, so
# preloading now populates the forecast cache with scores for the
# wrong viewport and forecast-hour clicks show a small square of
# coverage instead of the full map.
{:ok,
assign(socket,
page_title: "Propagation Prediction Map",
bands: bands,
selected_band: selected_band,
initial_scores_json: Jason.encode!(initial_scores),
valid_times: valid_times,
selected_time: selected_time,
bounds: bounds,
initial_center: center,
initial_zoom: @default_zoom,
outside_conus: outside_conus?(visitor),
grid_visible: false,
radar_visible: false,
antenna_height_ft: 33
)}
end
# Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
%{lat: lat, lon: lon}
end
defp initial_center(_session), do: @default_center
# Return the visitor's Cloudflare geolocation as a %{lat:, lon:} map, or
# nil when the CF headers weren't set (local dev, direct connect, tests
# without a stubbed session).
defp visitor_location(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
%{lat: lat, lon: lon}
end
defp visitor_location(_session), do: nil
# True when the visitor's location is known and falls outside the CONUS
# bounding box. Missing geolocation returns false so we don't flash a
# banner at anyone we can't actually place.
defp outside_conus?(nil), do: false
defp outside_conus?(%{lat: lat, lon: lon}) do
lat < @conus_lat_min or lat > @conus_lat_max or
lon < @conus_lon_min or lon > @conus_lon_max
end
# Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
# around a given center so the initial HRRR score query still returns a
# useful tile set for the visible area.
defp bounds_around(%{lat: lat, lon: lon}) do
%{
"south" => lat - 3.4,
"north" => lat + 3.4,
"west" => lon - 4.5,
"east" => lon + 4.5
}
end
@impl true
def handle_event("select_band", %{"value" => band}, socket) do
band = if is_binary(band), do: String.to_integer(band), else: band
valid_times = Propagation.available_valid_times(band)
selected_time = closest_to_now(valid_times)
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
send(self(), :preload_forecast)
socket =
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|> push_event("update_scores", %{scores: scores})
|> push_event("update_band_info", %{band_info: band_info(band)})
|> push_timeline()
{:noreply, socket}
end
def handle_event("select_time", %{"time" => time_str}, socket) do
case DateTime.from_iso8601(time_str) do
{:ok, time, _} ->
scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds)
socket =
socket
|> assign(:selected_time, time)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
# Fast path: the client already has the preloaded hour's scores in its
# local cache and only needs the server to remember which time is selected
# (used later for point_detail and forecast state on reconnect).
def handle_event("set_selected_time", %{"time" => time_str}, socket) do
case DateTime.from_iso8601(time_str) do
{:ok, time, _} -> {:noreply, assign(socket, :selected_time, time)}
_ -> {:noreply, socket}
end
end
def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do
height =
case Integer.parse(value) do
{h, _} when h >= 0 and h <= 200 -> h
_ -> 8
end
{:noreply, assign(socket, :antenna_height_ft, height)}
end
def handle_event("toggle_grid", _params, socket) do
visible = !socket.assigns.grid_visible
socket =
socket
|> assign(:grid_visible, visible)
|> push_event("toggle_grid", %{visible: visible})
{:noreply, socket}
end
def handle_event("toggle_radar", _params, socket) do
visible = !socket.assigns.radar_visible
socket =
socket
|> assign(:radar_visible, visible)
|> push_event("toggle_radar", %{visible: visible})
{:noreply, socket}
end
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
band = socket.assigns.selected_band
detail = Propagation.point_detail(band, lat, lon, socket.assigns.selected_time)
forecast = Propagation.point_forecast(band, lat, lon)
forecast_data =
Enum.map(forecast, fn f ->
%{time: DateTime.to_iso8601(f.valid_time), score: f.score}
end)
# Rain-scatter (NEXRAD cell overlay + scatter-dB readings in the detail
# panel) is disabled for now — the live radar WMS toggle covers the
# "is there rain?" question and the scatter-propagation feature needs
# more work before it's useful. To re-enable: restore the :rain_scatter
# payload key and the start_async/handle_async pair below.
payload =
if detail,
do: Map.put(detail, :forecast, forecast_data),
else: %{}
{:noreply, push_event(socket, "point_detail", payload)}
end
def handle_event("map_bounds", bounds, socket) do
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
# Refresh the forecast cache for the new viewport — the client cleared its
# local cache in sendBounds() so any timeline scrub would otherwise miss.
send(self(), :preload_forecast)
socket =
socket
|> assign(:bounds, bounds)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
band_mhz = socket.assigns.selected_band
band_config = BandConfig.get(band_mhz)
freq_ghz = band_mhz / 1_000
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
score =
case Propagation.point_detail(band_mhz, lat, lon) do
%{score: s} -> s
_ -> 50
end
max_range_km = score_range_km(score, band_config)
socket =
start_async(socket, :viewshed, fn ->
Viewshed.compute(lat, lon,
freq_ghz: freq_ghz,
ant_height_m: ant_height_m,
max_range_km: max_range_km,
score: score
)
end)
{:noreply, socket}
end
@impl true
def handle_info(:preload_forecast, socket) do
band = socket.assigns.selected_band
bounds = socket.assigns.bounds
selected = socket.assigns.selected_time
hours =
socket.assigns.valid_times
|> Enum.reject(&(selected && DateTime.compare(&1, selected) == :eq))
|> Enum.map(fn t ->
%{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)}
end)
{:noreply, push_event(socket, "preload_forecast", %{hours: hours})}
end
def handle_info({:propagation_updated, _valid_times}, socket) do
band = socket.assigns.selected_band
valid_times = Propagation.available_valid_times(band)
# Stay on the same selected time if still available, else pick earliest
selected_time =
if socket.assigns.selected_time in valid_times do
socket.assigns.selected_time
else
closest_to_now(valid_times)
end
# scores_at_fresh bypasses the cache and re-reads the .ntms file so
# the map reflects the newly written forecast hour even when the
# ScoreCache still holds the previous chain's bytes. The propagation
# update arrives via `propagation:updated` while the cache refresh
# rides `propagation:cache`; the two topics deliver independently
# so reading through the cache here can race against stale data.
scores =
if selected_time,
do: Propagation.scores_at_fresh(band, selected_time, socket.assigns.bounds),
else: []
send(self(), :preload_forecast)
socket =
socket
|> assign(valid_times: valid_times, selected_time: selected_time)
|> assign(:pipeline_status, PipelineStatus.current())
|> push_event("update_scores", %{scores: scores})
|> push_timeline()
{:noreply, socket}
end
def handle_info(:refresh_pipeline_status, socket) do
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
new_status = PipelineStatus.current()
progress =
if new_status.state == :running do
socket.assigns[:pipeline_progress]
end
socket =
socket
|> assign(:pipeline_status, new_status)
|> assign(:pipeline_progress, progress)
{:noreply, socket}
end
def handle_info({:propagation_pipeline_progress, progress}, socket) do
{:noreply, assign(socket, :pipeline_progress, progress)}
end
@impl true
def handle_async(:viewshed, {:ok, result}, socket) do
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
Logger.info("Viewshed complete: #{length(points)} boundary points")
socket =
push_event(socket, "viewshed_result", %{
origin: result.origin,
points: points
})
{:noreply, socket}
end
def handle_async(:viewshed, {:exit, reason}, socket) do
Logger.warning("Viewshed computation failed: #{inspect(reason)}")
{:noreply, socket}
end
defp push_timeline(socket) do
times =
Enum.map(socket.assigns.valid_times, fn t ->
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
end)
selected =
if socket.assigns.selected_time,
do: DateTime.to_iso8601(socket.assigns.selected_time)
push_event(socket, "update_timeline", %{times: times, selected: selected})
end
defp format_time_label(dt) do
# Show as "HH:MM" in UTC
Calendar.strftime(dt, "%H:%M")
end
@doc false
# Render the small "Data from HH:MM UTC · Nh ago" indicator that sits
# above the pipeline status chip. Tells the user at a glance which
# valid_time the map is currently showing when the bottom timeline
# isn't visible.
@spec format_data_timestamp(DateTime.t() | nil) :: String.t()
def format_data_timestamp(nil), do: "No data"
def format_data_timestamp(%DateTime{} = dt) do
diff_minutes = DateTime.diff(dt, DateTime.utc_now(), :minute)
relative = format_relative_minutes(diff_minutes)
"Data from #{Calendar.strftime(dt, "%H:%M")} UTC · #{relative}"
end
defp format_relative_minutes(diff) when diff >= -30 and diff <= 30, do: "now"
defp format_relative_minutes(diff) when diff > 30 do
# Future
hours = div(diff + 30, 60)
"+#{hours}h"
end
defp format_relative_minutes(diff) do
# Past
hours = div(-diff + 30, 60)
"#{hours}h ago"
end
defp closest_to_now([]), do: nil
defp closest_to_now(times) do
now = DateTime.utc_now()
Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end)
end
defp score_range_km(score, config) do
cond do
score >= 80 -> config.exceptional_range_km
score >= 65 -> config.extended_range_km
score >= 50 -> config.typical_range_km
score >= 33 -> round(config.typical_range_km * 0.6)
true -> round(config.typical_range_km * 0.3)
end
end
defp band_info(band_mhz) do
config = BandConfig.get(band_mhz)
%{
band_label: config.label,
typical_range_km: config.typical_range_km,
extended_range_km: config.extended_range_km,
exceptional_range_km: config.exceptional_range_km,
humidity_effect: to_string(config.humidity_effect),
freq_mhz: config.freq_mhz
}
end
defp selected_label(bands, selected_band) do
case Enum.find(bands, &(&1.freq_mhz == selected_band)) do
nil -> "Select Band"
band -> band.label
end
end
attr :id, :string, required: true
attr :status, :map, required: true
attr :progress, :any, default: nil
defp pipeline_status_chip(assigns) do
details = chip_detail_labels(assigns.status, assigns.progress)
assigns =
assigns
|> assign(:display_main, assigns.status.label)
|> assign(:display_details, details)
|> assign(:display_title, chip_title(assigns.status.label, details))
~H"""
<div
id={@id}
data-pipeline-state={@status.state}
class="flex items-start gap-1.5 text-xs px-1 py-1 leading-tight"
title={@display_title}
>
<%= case @status.state do %>
<% :running -> %>
<.icon
name="hero-arrow-path"
class="size-3.5 shrink-0 mt-0.5 text-info motion-safe:animate-spin"
/>
<span class="opacity-90 break-words min-w-0">
{@display_main}
<%= for d <- @display_details do %>
<span class="block text-[11px] opacity-70">{d}</span>
<% end %>
</span>
<% :idle -> %>
<span class="size-2 rounded-full bg-success inline-block shrink-0 mt-[5px]"></span>
<span class="opacity-70 break-words min-w-0">{@display_main}</span>
<% :stale -> %>
<span class="size-2 rounded-full bg-warning inline-block shrink-0 mt-[5px]"></span>
<span class="opacity-80 break-words min-w-0">{@display_main}</span>
<% :unknown -> %>
<span class="size-2 rounded-full bg-base-content/30 inline-block shrink-0 mt-[5px]"></span>
<span class="opacity-60 break-words min-w-0">{@display_main}</span>
<% end %>
</div>
"""
end
# Map the PipelineStatus details list to a plain list of sub-line
# strings. For the grid worker specifically, the forecast-hour
# progress payload overrides the base label so the chip can show
# "+3h" / "now" instead of the generic "HRRR run".
defp chip_detail_labels(%{state: :running, details: details}, progress) do
override = PipelineStatus.running_detail(progress)
Enum.map(details, fn
%{worker: :grid} -> override || "HRRR run"
%{label: label} -> label
end)
end
defp chip_detail_labels(_status, _progress), do: []
defp chip_title(main, []), do: main
defp chip_title(main, details), do: "#{main}#{Enum.join(details, " · ")}"
@impl true
def render(assigns) do
~H"""
<div class="flex w-screen h-screen overflow-hidden">
<%!-- Map container --%>
<div class="relative flex-1 min-w-0">
<%!-- CONUS-only data banner (shown only when visitor geolocation is
outside the continental US) --%>
<div
:if={@outside_conus}
id="conus-banner"
role="status"
class="alert alert-info absolute top-2 left-1/2 -translate-x-1/2 z-[1002] max-w-[calc(100vw-1rem)] md:max-w-xl shadow-lg text-sm"
>
<.icon name="hero-information-circle" class="size-5 shrink-0" />
<span>
This data set currently covers the continental United States only.
You're viewing the map centered on your location, but propagation
data won't be available there yet.
</span>
</div>
<%!-- Full-page map --%>
<div
id="propagation-map"
phx-hook="PropagationMap"
phx-update="ignore"
data-scores={@initial_scores_json}
data-band-info={Jason.encode!(band_info(@selected_band))}
data-valid-times={
Jason.encode!(
Enum.map(@valid_times, fn t ->
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
end)
)
}
data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
data-center-lat={@initial_center.lat}
data-center-lon={@initial_center.lon}
data-zoom={@initial_zoom}
class="absolute inset-0 z-0"
>
</div>
<%!-- Mobile-only floating controls --%>
<div
id="map-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="utc-clock"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div
id="data-timestamp-mobile"
class="text-[11px] opacity-70 px-1 leading-tight"
>
{format_data_timestamp(@selected_time)}
</div>
<.pipeline_status_chip
id="pipeline-status-mobile"
status={@pipeline_status}
progress={@pipeline_progress}
/>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
{selected_label(@bands, @selected_band)}
<.icon name="hero-chevron-down" class="size-3" />
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1"
>
<li :for={band <- @bands}>
<button
onclick="document.activeElement.blur()"
phx-click={
JS.dispatch("show-loading", to: "#propagation-map")
|> JS.push("select_band", value: %{value: band.freq_mhz})
}
class={[if(@selected_band == band.freq_mhz, do: "active")]}
>
{band.label}
</button>
</li>
</ul>
</div>
<div id="panel-extras" class="hidden flex-col gap-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
<label class="text-sm whitespace-nowrap">Antenna height</label>
<input
type="number"
name="height_ft"
min="0"
max="200"
step="1"
value={@antenna_height_ft}
class="input input-xs w-16 text-right"
/>
<span class="text-xs opacity-70">ft</span>
</form>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
Weather Map
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<%!-- Point detail floating panel --%>
<div
id="detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-14 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<%!-- Bottom timeline bar --%>
<div
id="forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<button
id="sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<%!-- Desktop right sidebar --%>
<div
id="sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<%!-- Sidebar controls --%>
<div class="p-3 flex flex-col gap-2 shrink-0">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('sidebar').style.display='none';document.getElementById('sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<%!-- Band selector --%>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
{selected_label(@bands, @selected_band)}
<.icon name="hero-chevron-down" class="size-3" />
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1 z-10"
>
<li :for={band <- @bands}>
<button
onclick="document.activeElement.blur()"
phx-click={
JS.dispatch("show-loading", to: "#propagation-map")
|> JS.push("select_band", value: %{value: band.freq_mhz})
}
class={[if(@selected_band == band.freq_mhz, do: "active")]}
>
{band.label}
</button>
</li>
</ul>
</div>
<%!-- Grid toggle --%>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<%!-- Weather radar toggle --%>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
<%!-- Antenna height --%>
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
<label class="text-sm whitespace-nowrap">Antenna height</label>
<input
type="number"
name="height_ft"
min="0"
max="200"
step="1"
value={@antenna_height_ft}
class="input input-xs w-16 text-right"
/>
<span class="text-xs opacity-70">ft</span>
</form>
<%!-- Navigation --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/weather">Weather Map</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li>
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
{@current_scope.user.callsign}
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
<div class="border-t border-base-300 pt-2">
<div
id="data-timestamp-desktop"
class="text-[11px] opacity-70 px-1 pb-1 leading-tight"
>
{format_data_timestamp(@selected_time)}
</div>
<.pipeline_status_chip
id="pipeline-status-desktop"
status={@pipeline_status}
progress={@pipeline_progress}
/>
</div>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end