The main map page now reads band, selected time, and map center/zoom from URL params and updates the URL as the user pans, zooms, picks a band, or scrubs the timeline. Pan/zoom use push_patch replace-mode so browser history isn't spammed, while band and time changes push fresh entries. Also fix the 'Up to date · just now ago' pipeline chip — 'just now' already reads as an instant so the trailing 'ago' is redundant. Any older duration keeps it.
995 lines
34 KiB
Elixir
995 lines
34 KiB
Elixir
defmodule MicrowavepropWeb.MapLive do
|
||
@moduledoc false
|
||
use MicrowavepropWeb, :live_view
|
||
use LiveStash
|
||
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
alias Microwaveprop.Propagation.PipelineStatus
|
||
alias Microwaveprop.Terrain.Viewshed
|
||
|
||
require Logger
|
||
|
||
@default_band 10_000
|
||
# Default map center when no visitor geolocation is available — DFW (EM12).
|
||
@default_center %{lat: 32.897, lon: -97.038}
|
||
@default_zoom 7
|
||
|
||
# Continental-US bounding box (excludes Alaska, Hawaii, Puerto Rico). Used
|
||
# to decide whether to show the "data is CONUS-only" banner to visitors
|
||
# whose Cloudflare geolocation falls outside the dataset's coverage.
|
||
@conus_lat_min 24.5
|
||
@conus_lat_max 49.5
|
||
@conus_lon_min -125.0
|
||
@conus_lon_max -66.5
|
||
|
||
# How often to re-query oban_jobs for pipeline state while the map is
|
||
# open. Short enough that the "Updating…" chip shows up within a page-
|
||
# visit for an in-progress hourly run, long enough to be cheap.
|
||
@pipeline_status_refresh_ms 15_000
|
||
|
||
@impl true
|
||
def mount(params, session, socket) do
|
||
if connected?(socket) do
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
||
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
|
||
|> assign(:pipeline_status, PipelineStatus.current())
|
||
|> assign(:pipeline_progress, nil)
|
||
|
||
# LiveStash only persists the keys passed to `stash_assigns/2`
|
||
# (selected_band + selected_time). On reconnect the recovered socket has
|
||
# just those — the rest of the mount-time assigns (bands, bounds, the
|
||
# initial score JSON payload, toggles, etc.) have to be rebuilt or the
|
||
# first render crashes with KeyError on :initial_scores_json.
|
||
{recovered_band, recovered_time, socket} =
|
||
case LiveStash.recover_state(socket) do
|
||
{:recovered, recovered} ->
|
||
{recovered.assigns[:selected_band], recovered.assigns[:selected_time], recovered}
|
||
|
||
_ ->
|
||
{nil, nil, socket}
|
||
end
|
||
|
||
bands = BandConfig.all_bands()
|
||
|
||
%{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)
|
||
initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)
|
||
|
||
# preload_forecast runs on the first map_bounds event instead of
|
||
# here — mount only knows a hardcoded fallback bounding box, so
|
||
# preloading now populates the forecast cache with scores for the
|
||
# wrong viewport and forecast-hour clicks show a small square of
|
||
# coverage instead of the full map.
|
||
|
||
build_ts = Microwaveprop.Application.build_timestamp()
|
||
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Propagation Prediction Map",
|
||
bands: bands,
|
||
selected_band: selected_band,
|
||
initial_scores_json: Jason.encode!(initial_scores),
|
||
valid_times: valid_times,
|
||
selected_time: selected_time,
|
||
bounds: bounds,
|
||
initial_center: center,
|
||
initial_zoom: 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.
|
||
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 = parse_time_param(params["t"]) || recovered_time || closest_to_now(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
|
||
|
||
# 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)
|
||
|
||
send(self(), :preload_forecast)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|
||
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|
||
|> push_event("update_scores", %{scores: scores})
|
||
|> push_event("update_band_info", %{band_info: band_info(band)})
|
||
|> push_timeline()
|
||
|> 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)
|
||
|> push_event("update_scores", %{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, _} ->
|
||
{:noreply, socket |> assign(:selected_time, time) |> patch_map_url()}
|
||
|
||
_ ->
|
||
{: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
|
||
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
|
||
|
||
# Refresh the forecast cache for the new viewport — the client cleared its
|
||
# local cache in sendBounds() so any timeline scrub would otherwise miss.
|
||
send(self(), :preload_forecast)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:bounds, bounds)
|
||
|> maybe_assign_center_zoom(bounds)
|
||
|> push_event("update_scores", %{scores: scores})
|
||
|> 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(:preload_forecast, socket) do
|
||
band = socket.assigns.selected_band
|
||
bounds = socket.assigns.bounds
|
||
selected = socket.assigns.selected_time
|
||
|
||
hours =
|
||
socket.assigns.valid_times
|
||
|> Enum.reject(&(selected && DateTime.compare(&1, selected) == :eq))
|
||
|> Enum.map(fn t ->
|
||
%{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)}
|
||
end)
|
||
|
||
{:noreply, push_event(socket, "preload_forecast", %{hours: hours})}
|
||
end
|
||
|
||
def handle_info({:propagation_updated, _valid_times}, socket) do
|
||
band = socket.assigns.selected_band
|
||
valid_times = Propagation.available_valid_times(band)
|
||
# Stay on the same selected time if still available, else pick earliest
|
||
selected_time =
|
||
if socket.assigns.selected_time in valid_times do
|
||
socket.assigns.selected_time
|
||
else
|
||
closest_to_now(valid_times)
|
||
end
|
||
|
||
# scores_at_fresh bypasses the cache and re-reads the .ntms file so
|
||
# the map reflects the newly written forecast hour even when the
|
||
# ScoreCache still holds the previous chain's bytes. The propagation
|
||
# update arrives via `propagation:updated` while the cache refresh
|
||
# rides `propagation:cache`; the two topics deliver independently
|
||
# so reading through the cache here can race against stale data.
|
||
scores =
|
||
if selected_time,
|
||
do: Propagation.scores_at_fresh(band, selected_time, socket.assigns.bounds),
|
||
else: []
|
||
|
||
send(self(), :preload_forecast)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(valid_times: valid_times, selected_time: selected_time)
|
||
|> assign(:pipeline_status, PipelineStatus.current())
|
||
|> push_event("update_scores", %{scores: scores})
|
||
|> push_timeline()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_info(:refresh_pipeline_status, socket) do
|
||
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
|
||
|
||
new_status = PipelineStatus.current()
|
||
|
||
progress =
|
||
if new_status.state == :running do
|
||
socket.assigns[:pipeline_progress]
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:pipeline_status, new_status)
|
||
|> assign(:pipeline_progress, progress)
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_info({:propagation_pipeline_progress, progress}, socket) do
|
||
{:noreply, assign(socket, :pipeline_progress, progress)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_async(:viewshed, {:ok, result}, socket) do
|
||
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
|
||
Logger.info("Viewshed complete: #{length(points)} boundary points")
|
||
|
||
socket =
|
||
push_event(socket, "viewshed_result", %{
|
||
origin: result.origin,
|
||
points: points
|
||
})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_async(:viewshed, {:exit, reason}, socket) do
|
||
Logger.warning("Viewshed computation failed: #{inspect(reason)}")
|
||
{:noreply, socket}
|
||
end
|
||
|
||
defp push_timeline(socket) do
|
||
times =
|
||
Enum.map(socket.assigns.valid_times, fn t ->
|
||
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
|
||
end)
|
||
|
||
selected =
|
||
if socket.assigns.selected_time,
|
||
do: DateTime.to_iso8601(socket.assigns.selected_time)
|
||
|
||
push_event(socket, "update_timeline", %{times: times, selected: selected})
|
||
end
|
||
|
||
defp format_time_label(dt) do
|
||
# Show as "HH:MM" in UTC
|
||
Calendar.strftime(dt, "%H:%M")
|
||
end
|
||
|
||
@doc false
|
||
# Render the small "Data from HH:MM UTC · Nh ago" indicator that sits
|
||
# above the pipeline status chip. Tells the user at a glance which
|
||
# valid_time the map is currently showing when the bottom timeline
|
||
# isn't visible.
|
||
@spec format_data_timestamp(DateTime.t() | nil) :: String.t()
|
||
def format_data_timestamp(nil), do: "No data"
|
||
|
||
def format_data_timestamp(%DateTime{} = dt) do
|
||
diff_minutes = DateTime.diff(dt, DateTime.utc_now(), :minute)
|
||
relative = format_relative_minutes(diff_minutes)
|
||
"Data from #{Calendar.strftime(dt, "%H:%M")} UTC · #{relative}"
|
||
end
|
||
|
||
defp format_relative_minutes(diff) when diff >= -30 and diff <= 30, do: "now"
|
||
|
||
defp format_relative_minutes(diff) when diff > 30 do
|
||
# Future
|
||
hours = div(diff + 30, 60)
|
||
"+#{hours}h"
|
||
end
|
||
|
||
defp format_relative_minutes(diff) do
|
||
# Past
|
||
hours = div(-diff + 30, 60)
|
||
"#{hours}h ago"
|
||
end
|
||
|
||
# --- 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
|
||
|
||
defp score_range_km(score, config) do
|
||
cond do
|
||
score >= 80 -> config.exceptional_range_km
|
||
score >= 65 -> config.extended_range_km
|
||
score >= 50 -> config.typical_range_km
|
||
score >= 33 -> round(config.typical_range_km * 0.6)
|
||
true -> round(config.typical_range_km * 0.3)
|
||
end
|
||
end
|
||
|
||
defp band_info(band_mhz) do
|
||
config = BandConfig.get(band_mhz)
|
||
|
||
%{
|
||
band_label: config.label,
|
||
typical_range_km: config.typical_range_km,
|
||
extended_range_km: config.extended_range_km,
|
||
exceptional_range_km: config.exceptional_range_km,
|
||
humidity_effect: to_string(config.humidity_effect),
|
||
freq_mhz: config.freq_mhz
|
||
}
|
||
end
|
||
|
||
defp selected_label(bands, selected_band) do
|
||
case Enum.find(bands, &(&1.freq_mhz == selected_band)) do
|
||
nil -> "Select Band"
|
||
band -> band.label
|
||
end
|
||
end
|
||
|
||
attr :id, :string, required: true
|
||
attr :status, :map, required: true
|
||
attr :progress, :any, default: nil
|
||
|
||
defp pipeline_status_chip(assigns) do
|
||
details = chip_detail_labels(assigns.status, assigns.progress)
|
||
|
||
assigns =
|
||
assigns
|
||
|> assign(:display_main, assigns.status.label)
|
||
|> assign(:display_details, details)
|
||
|> assign(:display_title, chip_title(assigns.status.label, details))
|
||
|
||
~H"""
|
||
<div
|
||
id={@id}
|
||
data-pipeline-state={@status.state}
|
||
class="flex items-start gap-1.5 text-xs px-1 py-1 leading-tight"
|
||
title={@display_title}
|
||
>
|
||
<%= case @status.state do %>
|
||
<% :running -> %>
|
||
<.icon
|
||
name="hero-arrow-path"
|
||
class="size-3.5 shrink-0 mt-0.5 text-info motion-safe:animate-spin"
|
||
/>
|
||
<span class="opacity-90 break-words min-w-0">
|
||
{@display_main}
|
||
<%= for d <- @display_details do %>
|
||
<span class="block text-[11px] opacity-70">{d}</span>
|
||
<% end %>
|
||
</span>
|
||
<% :idle -> %>
|
||
<span class="size-2 rounded-full bg-success inline-block shrink-0 mt-[5px]"></span>
|
||
<span class="opacity-70 break-words min-w-0">{@display_main}</span>
|
||
<% :stale -> %>
|
||
<span class="size-2 rounded-full bg-warning inline-block shrink-0 mt-[5px]"></span>
|
||
<span class="opacity-80 break-words min-w-0">{@display_main}</span>
|
||
<% :unknown -> %>
|
||
<span class="size-2 rounded-full bg-base-content/30 inline-block shrink-0 mt-[5px]"></span>
|
||
<span class="opacity-60 break-words min-w-0">{@display_main}</span>
|
||
<% end %>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
# Map the PipelineStatus details list to a plain list of sub-line
|
||
# strings. For the grid worker specifically, the forecast-hour
|
||
# progress payload overrides the base label so the chip can show
|
||
# "+3h" / "now" instead of the generic "HRRR run".
|
||
defp chip_detail_labels(%{state: :running, details: details}, progress) do
|
||
override = PipelineStatus.running_detail(progress)
|
||
|
||
Enum.map(details, fn
|
||
%{worker: :grid} -> override || "HRRR run"
|
||
%{label: label} -> label
|
||
end)
|
||
end
|
||
|
||
defp chip_detail_labels(_status, _progress), do: []
|
||
|
||
defp chip_title(main, []), do: main
|
||
defp chip_title(main, details), do: "#{main} — #{Enum.join(details, " · ")}"
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<div class="flex w-screen h-screen overflow-hidden">
|
||
<%!-- Map container --%>
|
||
<div class="relative flex-1 min-w-0">
|
||
<%!-- CONUS-only data banner (shown only when visitor geolocation is
|
||
outside the continental US) --%>
|
||
<div
|
||
:if={@outside_conus}
|
||
id="conus-banner"
|
||
role="status"
|
||
class="alert alert-info absolute top-2 left-1/2 -translate-x-1/2 z-[1002] max-w-[calc(100vw-1rem)] md:max-w-xl shadow-lg text-sm"
|
||
>
|
||
<.icon name="hero-information-circle" class="size-5 shrink-0" />
|
||
<span>
|
||
This data set currently covers the continental United States only.
|
||
You're viewing the map centered on your location, but propagation
|
||
data won't be available there yet.
|
||
</span>
|
||
</div>
|
||
|
||
<%!-- Full-page map --%>
|
||
<div
|
||
id="propagation-map"
|
||
phx-hook="PropagationMap"
|
||
phx-update="ignore"
|
||
data-scores={@initial_scores_json}
|
||
data-band-info={Jason.encode!(band_info(@selected_band))}
|
||
data-valid-times={
|
||
Jason.encode!(
|
||
Enum.map(@valid_times, fn t ->
|
||
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
|
||
end)
|
||
)
|
||
}
|
||
data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
|
||
data-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="/beacons" class="btn btn-xs btn-ghost justify-start">
|
||
Beacons
|
||
</.link>
|
||
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
||
Weather Map
|
||
</.link>
|
||
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
||
Submit a Contact
|
||
</.link>
|
||
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
||
Contact Training Data
|
||
</.link>
|
||
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
|
||
Contact Map
|
||
</.link>
|
||
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
||
Scoring Algorithm
|
||
</.link>
|
||
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
||
About
|
||
</.link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Point detail floating panel --%>
|
||
<div
|
||
id="detail-panel"
|
||
phx-update="ignore"
|
||
class={[
|
||
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
|
||
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
|
||
"md:absolute md:top-auto md:right-auto md:bottom-14 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
|
||
]}
|
||
style="display:none;"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Bottom timeline bar --%>
|
||
<div
|
||
id="forecast-timeline"
|
||
phx-update="ignore"
|
||
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
|
||
<button
|
||
id="sidebar-expand"
|
||
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
||
onclick="document.getElementById('sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||
>
|
||
<.icon name="hero-bars-3" class="size-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<%!-- Desktop right sidebar --%>
|
||
<div
|
||
id="sidebar"
|
||
data-theme="dark"
|
||
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
||
>
|
||
<%!-- Sidebar controls --%>
|
||
<div class="p-3 flex flex-col gap-2 shrink-0">
|
||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<span>NTMS</span>
|
||
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
<div
|
||
id="utc-clock-desktop"
|
||
phx-hook="UtcClock"
|
||
phx-update="ignore"
|
||
class="font-mono text-xs opacity-70 tabular-nums"
|
||
>
|
||
{@initial_utc_clock}
|
||
</div>
|
||
<button
|
||
id="sidebar-collapse"
|
||
class="btn btn-xs btn-square btn-ghost"
|
||
title="Collapse sidebar"
|
||
onclick="document.getElementById('sidebar').style.display='none';document.getElementById('sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||
>
|
||
<.icon name="hero-chevron-right" class="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Band selector --%>
|
||
<div class="dropdown">
|
||
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
||
{selected_label(@bands, @selected_band)}
|
||
<.icon name="hero-chevron-down" class="size-3" />
|
||
</div>
|
||
<ul
|
||
tabindex="0"
|
||
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1 z-10"
|
||
>
|
||
<li :for={band <- @bands}>
|
||
<button
|
||
onclick="document.activeElement.blur()"
|
||
phx-click={
|
||
JS.dispatch("show-loading", to: "#propagation-map")
|
||
|> JS.push("select_band", value: %{value: band.freq_mhz})
|
||
}
|
||
class={[if(@selected_band == band.freq_mhz, do: "active")]}
|
||
>
|
||
{band.label}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<%!-- Grid toggle --%>
|
||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||
<input
|
||
type="checkbox"
|
||
class="toggle toggle-sm toggle-primary"
|
||
checked={@grid_visible}
|
||
phx-click="toggle_grid"
|
||
/>
|
||
<span class="text-sm">Grid squares</span>
|
||
</label>
|
||
|
||
<%!-- Weather radar toggle --%>
|
||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||
<input
|
||
type="checkbox"
|
||
class="toggle toggle-sm toggle-primary"
|
||
checked={@radar_visible}
|
||
phx-click="toggle_radar"
|
||
/>
|
||
<span class="text-sm">Weather radar</span>
|
||
</label>
|
||
|
||
<%!-- Navigation --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<li>
|
||
<.link navigate="/path">Path Calculator</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/beacons">Beacons</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/weather">Weather Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/submit">Submit a Contact</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/contacts">Contact Training Data</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/contacts/map">Contact Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/algo">Scoring Algorithm</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/about">About</.link>
|
||
</li>
|
||
</ul>
|
||
|
||
<%!-- Auth --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<%= if @current_scope && @current_scope.user do %>
|
||
<li>
|
||
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
||
{@current_scope.user.callsign}
|
||
</.link>
|
||
</li>
|
||
<li>
|
||
<.link href={~p"/users/settings"}>Settings</.link>
|
||
</li>
|
||
<li>
|
||
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
||
</li>
|
||
<% else %>
|
||
<li>
|
||
<.link href={~p"/users/register"}>Register</.link>
|
||
</li>
|
||
<li>
|
||
<.link href={~p"/users/log-in"}>Log in</.link>
|
||
</li>
|
||
<% end %>
|
||
</ul>
|
||
|
||
<div class="border-t border-base-300 pt-2">
|
||
<div
|
||
id="data-timestamp-desktop"
|
||
class="text-[11px] opacity-70 px-1 pb-1 leading-tight"
|
||
>
|
||
{format_data_timestamp(@selected_time)}
|
||
</div>
|
||
|
||
<.pipeline_status_chip
|
||
id="pipeline-status-desktop"
|
||
status={@pipeline_status}
|
||
progress={@pipeline_progress}
|
||
/>
|
||
|
||
<div
|
||
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
|