prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire 2defa3db7a
refactor: deduplicate helpers and simplify Scorer
- Unify haversine_km / deg_to_rad: Geo is the canonical home (atan2
  form for antipodal stability); Radio, common_volume, rain_scatter,
  ionosphere, terrain/srtm, terrain/elevation_client now delegate.
- Drop safe_min/safe_max wrappers in favor of Enum.min/max defaults.
- Move parse_float / parse_int to MicrowavepropWeb.LiveHelpers; eme,
  path, and rover LiveViews import from there.
- Extract MicrowavepropWeb.LocationResolver from the eme/path
  resolve_source / resolve_location duplicate. Standardize the kind
  key and "Invalid grid square" error message.
- Convert Scorer cond chains (humidity, td_depression, sky, wind,
  rain, pwat, pressure) to multi-clause guarded defs, matching the
  existing classify_time_period style.
- extract_profile_fields uses a named map accumulator instead of a
  6-tuple; build_path_conditions guards on %{temps: []} / %{dewpoints: []}.
- Collapse scores_file clamp/3 to max |> min.
- humidity_effect_label lives in BandConfig; map_live and path_live
  both use it.
2026-04-28 14:14:34 -05:00

1257 lines
44 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
alias Phoenix.LiveView.AsyncResult
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
# 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, 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()
%{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)
# Two-stage mount: on the static HTTP render we still fetch scores
# synchronously so SEO/noscript/first-paint have real data baked into
# `data-scores`. On the subsequent websocket-connected mount we defer
# the potentially-slow score fetch to start_async/3 and let the chrome
# paint immediately, backfilling via `update_scores` push_event once
# the fetch resolves.
{initial_scores_json, initial_scores_assign, socket} =
if connected?(socket) do
socket =
start_async(socket, :initial_scores, fn ->
Propagation.scores_at(selected_band, selected_time, bounds)
end)
{"[]", AsyncResult.loading(), socket}
else
scores = Propagation.scores_at(selected_band, selected_time, bounds)
{Jason.encode!(Propagation.pack_scores(scores)), AsyncResult.ok(scores), socket}
end
# 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.
build_ts = Microwaveprop.Application.build_timestamp()
{:ok,
assign(socket,
page_title: "Propagation Prediction Map",
bands: bands,
selected_band: selected_band,
initial_scores_json: initial_scores_json,
initial_scores: initial_scores_assign,
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),
preload_forecast_timer: nil,
bounds_flush_timer: nil
)}
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)
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
socket = schedule_preload_forecast(socket)
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()
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> push_event("update_band_info", %{band_info: band_info(band)})
|> push_timeline()
|> patch_map_url()
{: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)
|> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> 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
# Leaflet fires `moveend` once per pan, but wheel-zoom can fire
# 510 per second. Without a debounce, each event kicked off a
# `scores_at` disk read + ~20100 KB websocket push. Debounce the
# score repaint to ~150ms — short enough that pans feel live
# (user releases → <1 frame latency) but absorbs bursts. The URL
# patch stays immediate so share-link state keeps up.
socket =
socket
|> assign(:bounds, bounds)
|> maybe_assign_center_zoom(bounds)
|> schedule_bounds_update()
|> schedule_preload_forecast()
|> 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
def handle_event("retry_initial_scores", _params, socket) do
band = socket.assigns.selected_band
time = socket.assigns.selected_time
bounds = socket.assigns.bounds
current = socket.assigns.initial_scores
socket =
socket
|> assign(:initial_scores, AsyncResult.loading(current))
|> start_async(:initial_scores, fn ->
Propagation.scores_at(band, time, bounds)
end)
{:noreply, socket}
end
@impl true
def handle_info(:flush_bounds, socket) do
scores =
Propagation.scores_at(
socket.assigns.selected_band,
socket.assigns.selected_time,
socket.assigns.bounds
)
{:noreply,
socket
|> assign(:bounds_flush_timer, nil)
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})}
end
def handle_info(:preload_forecast, socket) do
band = socket.assigns.selected_band
bounds = socket.assigns.bounds
selected = socket.assigns.selected_time
# Forecast preload walks up to 18 valid_times; each call reads the
# band's .prop file off NFS and filters to the viewport. The reads
# are independent, so fan them out across 4 Tasks — serial ran at
# ~18 × single-read latency, which dominated time-to-interactive
# after a band change on /map. Preserves the original ordering
# since the JS hook expects `hours` in valid_time order.
forecast_times = Enum.reject(socket.assigns.valid_times, &(selected && DateTime.compare(&1, selected) == :eq))
# `on_timeout: :kill_task` + explicit filter on :ok keeps a slow NFS
# read from cascading the whole stream's :timeout exit up into the
# LiveView process. A timed-out forecast hour just drops from the
# preload payload; the client re-requests it on demand.
hours =
forecast_times
|> Task.async_stream(
fn t ->
%{
time: DateTime.to_iso8601(t),
scores: Propagation.pack_scores(Propagation.scores_at(band, t, bounds))
}
end,
max_concurrency: 4,
ordered: true,
timeout: 10_000,
on_timeout: :kill_task
)
|> Enum.zip(forecast_times)
|> Enum.flat_map(fn
{{:ok, h}, _t} ->
[h]
{{:exit, reason}, t} ->
Logger.error(
"MapLive forecast preload task crashed: band=#{band} valid_time=#{inspect(t)} reason=#{inspect(reason)}"
)
[]
end)
{:noreply,
socket
|> assign(:preload_forecast_timer, nil)
|> push_event("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 .prop 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: []
socket = schedule_preload_forecast(socket)
socket =
socket
|> assign(valid_times: valid_times, selected_time: selected_time)
|> assign(:pipeline_status, PipelineStatus.current())
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> 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
scores = Propagation.scores_at(socket.assigns.selected_band, new_time, socket.assigns.bounds)
socket =
socket
|> assign(:selected_time, new_time)
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> 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(:initial_scores, {:ok, scores}, socket) do
current = socket.assigns.initial_scores
socket =
socket
|> assign(:initial_scores, AsyncResult.ok(current, scores))
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
{:noreply, socket}
end
def handle_async(:initial_scores, {:exit, reason}, socket) do
Logger.warning("Initial score fetch failed: #{inspect(reason)}")
current = socket.assigns.initial_scores
{:noreply, assign(socket, :initial_scores, AsyncResult.failed(current, reason))}
end
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
# Leaflet fires `moveend` on every pan + zoom, so bounds events arrive
# in bursts during interactive map use. Each :preload_forecast delivers
# 18 forecast-hour score lists filtered to the current viewport (up to
# ~90k cells per hour at full CONUS view), so firing it per moveend
# produced an 18× read + large websocket payload per pan. Debounce to
# the trailing edge: the user has stopped moving for 750 ms before we
# pay the preload cost.
@preload_debounce_ms 750
defp schedule_preload_forecast(%{assigns: %{preload_forecast_timer: ref}} = socket) when is_reference(ref) do
_ = Process.cancel_timer(ref)
do_schedule_preload_forecast(socket)
end
defp schedule_preload_forecast(socket), do: do_schedule_preload_forecast(socket)
defp do_schedule_preload_forecast(socket) do
ref = Process.send_after(self(), :preload_forecast, @preload_debounce_ms)
assign(socket, :preload_forecast_timer, ref)
end
# Debounce the `update_scores` repaint so a wheel-zoom burst (510
# moveend events per second) doesn't run 10× `scores_at` disk reads
# or push 10× the wire payload. 150 ms is short enough that the
# user doesn't perceive it as lag.
@bounds_flush_debounce_ms 150
defp schedule_bounds_update(%{assigns: %{bounds_flush_timer: ref}} = socket) when is_reference(ref) do
_ = Process.cancel_timer(ref)
do_schedule_bounds_update(socket)
end
defp schedule_bounds_update(socket), do: do_schedule_bounds_update(socket)
defp do_schedule_bounds_update(socket) do
ref = Process.send_after(self(), :flush_bounds, @bounds_flush_debounce_ms)
assign(socket, :bounds_flush_timer, ref)
end
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-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-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>
<%!-- Async loading state for the first score fetch. The map chrome
renders immediately; this overlay shows a spinner while scores
stream in over the websocket and surfaces a retry button if the
fetch fails. --%>
<.async_result :let={_scores} assign={@initial_scores}>
<:loading>
<div
id="initial-scores-loading"
role="status"
aria-live="polite"
class="absolute top-2 right-2 z-[1002] bg-neutral text-neutral-content shadow-lg rounded-box border border-base-300 px-3 py-2 text-xs flex items-center gap-2"
>
<.icon name="hero-arrow-path" class="size-3.5 motion-safe:animate-spin" />
<span>Loading propagation scores…</span>
</div>
</:loading>
<:failed :let={_reason}>
<div
id="initial-scores-failed"
role="alert"
class="absolute top-2 right-2 z-[1002] bg-error text-error-content shadow-lg rounded-box px-3 py-2 text-xs flex items-center gap-2"
>
<.icon name="hero-exclamation-triangle" class="size-3.5" />
<span>Couldn't load scores.</span>
<button type="button" class="btn btn-xs btn-ghost" phx-click="retry_initial_scores">
Retry
</button>
</div>
</:failed>
</.async_result>
<%!-- 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