prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire 1da78a80e5
feat(hrdps): activate Canadian propagation chain end-to-end
Stages 6, 7, 10 of the HRDPS plan, plus the Elixir read-side merge
that makes Rust's `.hrdps.prop` writes show up on /map.

Read side:

- `ScoresFile.path_for_hrdps/2` + `read_hrdps/2` — companions to the
  HRRR equivalents. Same decode path; different filename.
- `ScoresFile.read_bounds/3` now reads HRRR + HRDPS files and
  concatenates the cells. Files are disjoint by construction
  (Grid.hrdps_only_points excludes CONUS) so no de-dup needed.
- `ScoresFile.read_point/4` checks `.prop`, then `.hrdps.prop`, then
  legacy `.ntms` — first hit wins. Cells outside their owning region
  read as no-data in the other file's grid, so the order doesn't
  matter for correctness.
- `Propagation.warm_cache_and_broadcast/2` reads both files and
  merges before broadcasting to the cluster ScoreCache. A single
  missing file (HRDPS pre-cycle, HRRR briefly absent) is now OK —
  the cache warms with whichever side is available.

Activation:

- runtime.exs cron: HRDPS seed worker fires at HH:35 of each cycle
  hour (05:35Z, 11:35Z, 17:35Z, 23:35Z) — 5h after cycle, 30 min
  staggered from HRRR's */15 schedule.
- HrdpsGridWorker moduledoc updated: no longer dormant; Rust
  prop-grid-rs handles source='hrdps' rows.
- map_live North America bbox extended to (24.5, 60.0, -141.0, -52.0)
  for the out-of-coverage banner check; visitors anywhere in CONUS
  or the Canadian extent now stay banner-free.

Cleanup:

- mix hrdps.probe deleted — its risks are retired and the production
  HrdpsClient covers the same surface.

What's still in motion (not blocking the Canadian /map):

- Per-valid_time profile + scalar artifacts for HRDPS (would let
  /weather show Canadian temperature/refractivity layers). Rust
  pipeline currently skips those so the HRRR-written companion files
  aren't clobbered — separate stage when /weather UX is decided.
- FreshnessMonitor HRDPS staleness tracking — HRDPS has its own cron
  and a different cadence than HRRR; HRRR-stale logic doesn't apply
  cleanly. Defer until prod tells us what alarms we actually want.
2026-04-29 17:29:37 -05:00

1043 lines
36 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 "`/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)
bounds = bounds_around(center)
# 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()),
bounds: bounds,
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
# 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_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
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)
socket =
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()
{:noreply, socket}
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
@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>
<Layouts.flash_group flash={@flash} />
"""
end
end