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).
890 lines
31 KiB
Elixir
890 lines
31 KiB
Elixir
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
|