prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -05:00

1055 lines
37 KiB
Elixir

defmodule MicrowavepropWeb.MapLive do
@moduledoc "`/map` — main propagation heatmap with forecast timeline + point detail drawer."
use MicrowavepropWeb, :live_view
use LiveStash, stored_keys: [:selected_band, :selected_time]
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
# North American coverage bbox: HRRR (CONUS) + HRDPS (Canadian extent
# to 60°N — Arctic deferred until SRTM gap is filled). Used to decide
# whether to show the "out of coverage area" banner to visitors whose
# Cloudflare geolocation falls outside the combined dataset.
@conus_lat_min 24.5
@conus_lat_max 60.0
@conus_lon_min -141.0
@conus_lon_max -52.0
# 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
# How often to check whether the wall clock has ticked into a new
# forecast hour. 60s is frequent enough to advance within a minute
# of top-of-hour and cheap enough to run for every connected map.
@advance_now_cursor_ms 60_000
@impl true
def mount(params, session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
_ = Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
_ = Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_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 declared in `use LiveStash,
# stored_keys: [...]` (selected_band + selected_time). On reconnect
# the recovered socket has just those — the rest of the mount-time
# assigns (bands, bounds, toggles, etc.) have to be rebuilt or the
# first render crashes with KeyError.
{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()
%{band: selected_band, time: selected_time, center: center, zoom: zoom, valid_times: valid_times} =
resolve_view(params, session, recovered_band, recovered_time)
visitor = visitor_location(session)
# Score data is fetched by the client over HTTP (see /scores/cells
# and propagation_map_hook.ts). The LiveView only owns timeline,
# band selection, point detail, viewshed, and URL state.
build_ts = Microwaveprop.Application.build_timestamp()
{:ok,
assign(socket,
page_title: "Propagation Prediction Map",
bands: bands,
selected_band: selected_band,
valid_times: valid_times,
selected_time: selected_time,
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
initial_center: center,
initial_zoom: zoom,
current_center: center,
current_zoom: zoom,
outside_conus: outside_conus?(visitor),
grid_visible: false,
radar_visible: false,
deploy_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"),
deploy_ago: format_deploy_ago(build_ts)
)}
end
# Compact relative-time rendering of the build timestamp. Matches the
# format used by Layouts.deploy_stamp/1 so the nav and the map
# sidebar stay consistent.
defp format_deploy_ago(%DateTime{} = dt) do
diff = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86_400 -> "#{div(diff, 3600)}h ago"
true -> "#{div(diff, 86_400)}d ago"
end
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
# Resolve the initial band/time/center/zoom from URL params, falling
# back to LiveStash recovery, then session geolocation and defaults.
# Parsers return nil for anything malformed so `||` walks to the next
# sensible source; ISO8601 times that sit outside the current HRRR
# window get dropped too so a stale shared link doesn't land the map
# on a hour with no scores.
defp resolve_view(params, session, recovered_band, recovered_time) do
band = parse_band_param(params["band"]) || recovered_band || @default_band
valid_times = Propagation.available_valid_times(band)
time = resolve_time(params["t"], recovered_time, valid_times)
center = parse_center_param(params["lat"], params["lon"]) || initial_center(session)
zoom = parse_zoom_param(params["zoom"]) || @default_zoom
%{band: band, time: time, center: center, zoom: zoom, valid_times: valid_times}
end
defp resolve_time(param, recovered_time, valid_times) do
case parse_time_param(param) do
%DateTime{} = t ->
if time_in_window?(t, valid_times), do: t, else: closest_to_now(valid_times)
nil ->
recovered_time || closest_to_now(valid_times)
end
end
defp time_in_window?(_, []), do: false
defp time_in_window?(%DateTime{} = t, valid_times) do
earliest = Enum.min(valid_times, DateTime)
latest = Enum.max(valid_times, DateTime)
DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt
end
@impl true
def handle_params(_params, _url, socket) do
# URL params are applied in mount/3; subsequent push_patch calls
# hit this callback with the same params that our own push_patch
# just wrote, so there's nothing further to reconcile here.
{:noreply, socket}
end
@impl true
def handle_event("select_band", %{"value" => band}, socket) do
case normalize_band_event(band) do
nil ->
# Reject unknown / unparseable bands rather than crashing the
# LiveView with String.to_integer/BandConfig.get failures.
{:noreply, socket}
band ->
do_select_band(socket, band)
end
end
def handle_event("select_time", %{"time" => time_str}, socket) do
# Same as set_selected_time — client owns the score fetch via HTTP.
# Kept as a separate event because the click target predates the
# split and cleaning every caller up isn't necessary.
case DateTime.from_iso8601(time_str) do
{:ok, time, _} ->
socket =
socket
|> assign(:selected_time, time)
|> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
|> patch_map_url()
{: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, _} ->
socket =
socket
|> assign(:selected_time, time)
|> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
|> patch_map_url()
{:noreply, socket}
_ ->
{:noreply, socket}
end
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
# The client owns the score fetch via /scores/cells now — this
# event only updates server-side bounds for URL patching, point
# detail bbox lookups, and reconnect state.
socket =
socket
|> assign(:bounds, bounds)
|> maybe_assign_center_zoom(bounds)
|> patch_map_url(replace: true)
{: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
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: 10.0,
max_range_km: max_range_km,
score: score
)
end)
{:noreply, socket}
end
@impl true
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
# The client's score cache may be stale for the new forecast hours,
# but it'll refetch via /scores/cells when the user scrubs (and the
# update_timeline push below makes the new hours selectable).
socket =
socket
|> assign(valid_times: valid_times, selected_time: selected_time)
|> assign(:pipeline_status, PipelineStatus.current())
|> push_timeline()
{:noreply, socket}
end
def handle_info(:advance_now_cursor, socket) do
Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms)
if socket.assigns.tracking_now? do
new_time = closest_to_now(socket.assigns.valid_times)
if new_time && socket.assigns.selected_time &&
DateTime.after?(new_time, socket.assigns.selected_time) do
# Client picks up the cursor advance via update_timeline and
# refetches scores via HTTP for the new selected time.
socket =
socket
|> assign(:selected_time, new_time)
|> push_timeline()
|> patch_map_url(replace: true)
{:noreply, socket}
else
{:noreply, socket}
end
else
{:noreply, socket}
end
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
# --- URL param plumbing -----------------------------------------------
# The JS hook piggybacks map center + zoom onto the bounds event
# so the URL can carry a shareable view. Old payloads (no center)
# should still work — fall back to keeping whatever we had.
defp maybe_assign_center_zoom(socket, %{"center_lat" => lat, "center_lon" => lon, "zoom" => zoom})
when is_number(lat) and is_number(lon) and is_number(zoom) do
assign(socket, current_center: %{lat: lat, lon: lon}, current_zoom: trunc(zoom))
end
defp maybe_assign_center_zoom(socket, _), do: socket
defp patch_map_url(socket, opts \\ []) do
push_patch(socket, to: map_url(socket.assigns), replace: Keyword.get(opts, :replace, false))
end
defp map_url(assigns) do
params =
%{}
|> Map.put("band", assigns.selected_band)
|> put_if(assigns.selected_time, "t", fn dt -> DateTime.to_iso8601(dt) end)
|> put_if(assigns[:current_center], "lat", fn c -> Float.round(c.lat, 3) end)
|> put_if(assigns[:current_center], "lon", fn c -> Float.round(c.lon, 3) end)
|> put_if(assigns[:current_zoom], "zoom", fn z -> trunc(z) end)
"/map?" <> URI.encode_query(params)
end
defp put_if(map, nil, _key, _fun), do: map
defp put_if(map, value, key, fun), do: Map.put(map, key, fun.(value))
@doc false
def parse_band_param(nil), do: nil
def parse_band_param(str) when is_binary(str) do
case Integer.parse(str) do
{n, ""} -> if BandConfig.get(n), do: n
_ -> nil
end
end
defp normalize_band_event(band) when is_integer(band), do: if(BandConfig.get(band), do: band)
defp normalize_band_event(band) when is_binary(band), do: parse_band_param(band)
defp normalize_band_event(_), do: nil
defp do_select_band(socket, band) do
valid_times = Propagation.available_valid_times(band)
selected_time = closest_to_now(valid_times)
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|> MicrowavepropWeb.LiveStashGuard.stash()
# band_mhz tells the client to refetch /scores/cells for the new band.
|> push_event("update_band_info", %{band_info: band_info(band), band_mhz: band})
|> push_timeline()
|> patch_map_url()
|> then(&{:noreply, &1})
end
@doc false
def parse_time_param(nil), do: nil
def parse_time_param(str) when is_binary(str) do
case DateTime.from_iso8601(str) do
{:ok, dt, _} -> DateTime.truncate(dt, :second)
_ -> nil
end
end
@doc false
def parse_center_param(lat_s, lon_s) when is_binary(lat_s) and is_binary(lon_s) do
with {lat, ""} <- Float.parse(lat_s),
{lon, ""} <- Float.parse(lon_s),
true <- lat >= -90.0 and lat <= 90.0,
true <- lon >= -180.0 and lon <= 180.0 do
%{lat: lat, lon: lon}
else
_ -> nil
end
end
def parse_center_param(_, _), do: nil
@doc false
def parse_zoom_param(nil), do: nil
def parse_zoom_param(str) when is_binary(str) do
case Integer.parse(str) do
{n, ""} when n >= 1 and n <= 18 -> n
_ -> nil
end
end
# --- end URL param plumbing -------------------------------------------
defp closest_to_now(times), do: cursor_for_now(times, DateTime.utc_now())
@doc false
# Picks the forecast valid_time the map should display as "Now".
# The rule is "latest valid_time at or before `now`" — a nearest-by-
# absolute-distance pick can land on a future hour (e.g. at 18:35 UTC,
# 19:00 is 25 min away vs. 18:00 at 35 min, so nearest would label
# 19:00 "Now"). Falls back to the earliest time only if every slot is
# in the future, which HRRR analysis + forecast lists shouldn't produce.
@spec cursor_for_now([DateTime.t()], DateTime.t()) :: DateTime.t() | nil
def cursor_for_now([], _now), do: nil
def cursor_for_now(times, %DateTime{} = now) do
past_or_now = Enum.filter(times, &(DateTime.compare(&1, now) != :gt))
case past_or_now do
[] -> Enum.min_by(times, &DateTime.to_unix/1)
past -> Enum.max_by(past, &DateTime.to_unix/1)
end
end
@doc false
# True when the map's current selected_time is still the "Now" slot,
# i.e. the user hasn't scrubbed to a specific past/future hour. Used
# by :advance_now_cursor to decide whether to auto-follow the wall
# clock when it ticks into the next hour.
@spec tracking_now?(DateTime.t() | nil, [DateTime.t()], DateTime.t()) :: boolean()
def tracking_now?(nil, _times, _now), do: false
def tracking_now?(_selected, [], _now), do: false
def tracking_now?(%DateTime{} = selected, times, %DateTime{} = now) do
case cursor_for_now(times, now) do
nil -> false
cursor -> DateTime.compare(selected, cursor) == :eq
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: BandConfig.humidity_effect_label(config),
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-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-selected-band={@selected_band}
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>
<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="/eme" class="btn btn-xs btn-ghost justify-start">
EME
</.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-24 md:left-3 md:max-h-[calc(100vh-8rem)] 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>
<%!-- 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="/eme">EME</.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
class="text-[11px] opacity-60 px-1 pt-2 leading-tight"
title={"Last deployed #{@deploy_iso}"}
>
Deployed {@deploy_ago}
</div>
</div>
</div>
</div>
</div>
<div role="alert" class="fixed top-2 right-2 z-[2000] flex flex-col gap-2 pointer-events-none">
<div
:if={info = Phoenix.Flash.get(@flash, :info)}
class="alert alert-info pointer-events-auto shadow-lg"
>
{info}
</div>
<div
:if={error = Phoenix.Flash.get(@flash, :error)}
class="alert alert-error pointer-events-auto shadow-lg"
>
{error}
</div>
</div>
"""
end
end