- accounts.ex: replace if with with-chain in get_user_by_email_and_password for explicit pattern matching on User struct + valid_password? result. Collapse nested case into with in create_api_token. - map_live.ex: extract connected? PubSub subscription block from mount into named subscribe_if_connected/1 function (cleaner than inline if with _ =). Deduplicate identical select_time + set_selected_time handlers into shared handle_set_time/2 helper. Extract toggle_grid + toggle_radar into shared handle_toggle/3 helper (functions-as-values pattern for assign key).
1045 lines
36 KiB
Elixir
1045 lines
36 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
|
|
socket = subscribe_if_connected(socket)
|
|
|
|
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
|
|
|
|
defp subscribe_if_connected(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
|
|
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
|
|
handle_set_time(time_str, socket)
|
|
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
|
|
handle_set_time(time_str, socket)
|
|
end
|
|
|
|
def handle_event("toggle_grid", _params, socket) do
|
|
handle_toggle(socket, :grid_visible, "toggle_grid")
|
|
end
|
|
|
|
def handle_event("toggle_radar", _params, socket) do
|
|
handle_toggle(socket, :radar_visible, "toggle_radar")
|
|
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
|
|
|> 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
|
|
|
|
# -- event handler helpers (grouped after handle_event clauses) --
|
|
|
|
defp handle_set_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
|
|
|
|
defp handle_toggle(socket, assign_key, event_name) do
|
|
visible = !socket.assigns[assign_key]
|
|
{:noreply, socket |> assign(assign_key, visible) |> push_event(event_name, %{visible: visible})}
|
|
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
|
|
@spec parse_band_param(String.t() | nil) :: integer() | nil
|
|
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
|
|
@spec parse_time_param(String.t() | nil) :: DateTime.t() | nil
|
|
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
|
|
@spec parse_center_param(String.t(), String.t()) :: map() | nil
|
|
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
|
|
@spec parse_zoom_param(String.t() | nil) :: integer() | nil
|
|
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
|