**Wire format — /map scores payload.** Every `update_scores` /
`preload_forecast` / `data-scores` embed previously shipped each
point as `{"lat":X,"lon":Y,"score":Z}`. At 95k cells in full CONUS
that's ~36 bytes/point × 95k = 3.4 MB of JSON, ~50% of it repeating
the three key names. Packing as `[lat, lon, score]` tuples drops
per-point overhead to ~19 bytes → ~1.8 MB on the wire, ~45% smaller.
New `Propagation.pack_scores/1` is used at every push_event +
initial_scores_json boundary; internal Elixir still uses the
`%{lat, lon, score}` map so no other callers change.
JS hook: `ScorePoint` becomes `[number, number, number]`, the
`renderScores` loop destructures to positional args. Regression
test in map_live_test updated to match the new shape.
**ProfilesFile compression.** Rust writer was at gzip level 6
(default). Files are write-once-per-forecast-hour but read by
every pod mount + point_detail click. Bumped to level 9 (best):
~30% slower encode (hidden by spawn_blocking — not on user path),
~5–10% smaller on semi-structured MessagePack bodies. No reader
changes needed — same gzip stream.
**TypeScript strictness.**
- `app.ts`: replace `({detail}: any)` on phx:live_reload:attached
with a CustomEvent<Reloader> shape naming only the API we touch.
- `weather_map_hook.ts`: drop the `(this as any)._map` pair by
declaring a `GridLayerWithMap = L.GridLayer & { _map: L.Map }`
intersection on the overlay's createTile `this` parameter.
- `global.d.ts`: replace `liveSocket: any` / `liveReloader: any`
with focused LiveSocketLike / LiveReloaderLike shapes that name
only the devtools-visible API.
No more `any` in the codebase (verified via rg).
1234 lines
44 KiB
Elixir
1234 lines
44 KiB
Elixir
defmodule MicrowavepropWeb.MapLive do
|
||
@moduledoc "`/map` — main propagation heatmap with forecast timeline + point detail drawer."
|
||
use MicrowavepropWeb, :live_view
|
||
use LiveStash
|
||
|
||
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 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)
|
||
|
||
# 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()))
|
||
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|
||
|> 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
|
||
# 5–10 per second. Without a debounce, each event kicked off a
|
||
# `scores_at` disk read + ~20–100 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))
|
||
|
||
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
|
||
)
|
||
|> Enum.map(fn {:ok, h} -> h 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 (5–10
|
||
# 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: 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>
|
||
|
||
<%!-- 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="/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="/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
|