defmodule MicrowavepropWeb.MapLive do @moduledoc "`/map` — main propagation heatmap with forecast timeline + point detail drawer." use MicrowavepropWeb, :live_view use LiveStash, stored_keys: [:selected_band, :selected_time] alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.PipelineStatus alias Microwaveprop.Terrain.Viewshed alias Phoenix.LiveView.AsyncResult require Logger @default_band 10_000 # Default map center when no visitor geolocation is available — DFW (EM12). @default_center %{lat: 32.897, lon: -97.038} @default_zoom 7 # Continental-US bounding box (excludes Alaska, Hawaii, Puerto Rico). Used # to decide whether to show the "data is CONUS-only" banner to visitors # whose Cloudflare geolocation falls outside the dataset's coverage. @conus_lat_min 24.5 @conus_lat_max 49.5 @conus_lon_min -125.0 @conus_lon_max -66.5 # How often to re-query oban_jobs for pipeline state while the map is # open. Short enough that the "Updating…" chip shows up within a page- # visit for an in-progress hourly run, long enough to be cheap. @pipeline_status_refresh_ms 15_000 # How often to check whether the wall clock has ticked into a new # forecast hour. 60s is frequent enough to advance within a minute # of top-of-hour and cheap enough to run for every connected map. @advance_now_cursor_ms 60_000 @impl true def mount(params, session, socket) do _ = if connected?(socket) do :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline") _ = Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms) _ = Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms) end socket = socket |> assign(:initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC")) |> assign(:pipeline_status, PipelineStatus.current()) |> assign(:pipeline_progress, nil) # LiveStash only persists the keys declared in `use LiveStash, # stored_keys: [...]` (selected_band + selected_time). On reconnect # the recovered socket has # just those — the rest of the mount-time assigns (bands, bounds, the # initial score JSON payload, toggles, etc.) have to be rebuilt or the # first render crashes with KeyError on :initial_scores_json. {recovered_band, recovered_time, socket} = case LiveStash.recover_state(socket) do {:recovered, recovered} -> {recovered.assigns[:selected_band], recovered.assigns[:selected_time], recovered} _ -> {nil, nil, socket} end bands = BandConfig.all_bands() %{band: selected_band, time: selected_time, center: center, zoom: zoom, valid_times: valid_times} = resolve_view(params, session, recovered_band, recovered_time) visitor = visitor_location(session) bounds = bounds_around(center) # Two-stage mount: on the static HTTP render we still fetch scores # synchronously so SEO/noscript/first-paint have real data baked into # `data-scores`. On the subsequent websocket-connected mount we defer # the potentially-slow score fetch to start_async/3 and let the chrome # paint immediately, backfilling via `update_scores` push_event once # the fetch resolves. {initial_scores_json, initial_scores_assign, socket} = if connected?(socket) do socket = start_async(socket, :initial_scores, fn -> Propagation.scores_at(selected_band, selected_time, bounds) end) {"[]", AsyncResult.loading(), socket} else scores = Propagation.scores_at(selected_band, selected_time, bounds) {Jason.encode!(Propagation.pack_scores(scores)), AsyncResult.ok(scores), socket} end # preload_forecast runs on the first map_bounds event instead of # here — mount only knows a hardcoded fallback bounding box, so # preloading now populates the forecast cache with scores for the # wrong viewport and forecast-hour clicks show a small square of # coverage instead of the full map. build_ts = Microwaveprop.Application.build_timestamp() {:ok, assign(socket, page_title: "Propagation Prediction Map", bands: bands, selected_band: selected_band, initial_scores_json: initial_scores_json, initial_scores: initial_scores_assign, valid_times: valid_times, selected_time: selected_time, tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()), bounds: bounds, initial_center: center, initial_zoom: zoom, current_center: center, current_zoom: zoom, outside_conus: outside_conus?(visitor), grid_visible: false, radar_visible: false, deploy_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"), deploy_ago: format_deploy_ago(build_ts), preload_forecast_timer: nil, bounds_flush_timer: nil )} end # Compact relative-time rendering of the build timestamp. Matches the # format used by Layouts.deploy_stamp/1 so the nav and the map # sidebar stay consistent. defp format_deploy_ago(%DateTime{} = dt) do diff = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0) cond do diff < 60 -> "just now" diff < 3600 -> "#{div(diff, 60)}m ago" diff < 86_400 -> "#{div(diff, 3600)}h ago" true -> "#{div(diff, 86_400)}d ago" end end # Prefer the visitor's Cloudflare geolocation when present; fall back to DFW. defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do %{lat: lat, lon: lon} end defp initial_center(_session), do: @default_center # Return the visitor's Cloudflare geolocation as a %{lat:, lon:} map, or # nil when the CF headers weren't set (local dev, direct connect, tests # without a stubbed session). defp visitor_location(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do %{lat: lat, lon: lon} end defp visitor_location(_session), do: nil # True when the visitor's location is known and falls outside the CONUS # bounding box. Missing geolocation returns false so we don't flash a # banner at anyone we can't actually place. defp outside_conus?(nil), do: false defp outside_conus?(%{lat: lat, lon: lon}) do lat < @conus_lat_min or lat > @conus_lat_max or lon < @conus_lon_min or lon > @conus_lon_max end # Resolve the initial band/time/center/zoom from URL params, falling # back to LiveStash recovery, then session geolocation and defaults. # Parsers return nil for anything malformed so `||` walks to the next # sensible source; ISO8601 times that sit outside the current HRRR # window get dropped too so a stale shared link doesn't land the map # on a hour with no scores. defp resolve_view(params, session, recovered_band, recovered_time) do band = parse_band_param(params["band"]) || recovered_band || @default_band valid_times = Propagation.available_valid_times(band) time = resolve_time(params["t"], recovered_time, valid_times) center = parse_center_param(params["lat"], params["lon"]) || initial_center(session) zoom = parse_zoom_param(params["zoom"]) || @default_zoom %{band: band, time: time, center: center, zoom: zoom, valid_times: valid_times} end defp resolve_time(param, recovered_time, valid_times) do case parse_time_param(param) do %DateTime{} = t -> if time_in_window?(t, valid_times), do: t, else: closest_to_now(valid_times) nil -> recovered_time || closest_to_now(valid_times) end end defp time_in_window?(_, []), do: false defp time_in_window?(%DateTime{} = t, valid_times) do earliest = Enum.min(valid_times, DateTime) latest = Enum.max(valid_times, DateTime) DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt end # Build a bounding box roughly matching the hardcoded default (~3.4° × 9°) # around a given center so the initial HRRR score query still returns a # useful tile set for the visible area. defp bounds_around(%{lat: lat, lon: lon}) do %{ "south" => lat - 3.4, "north" => lat + 3.4, "west" => lon - 4.5, "east" => lon + 4.5 } end @impl true def handle_params(_params, _url, socket) do # URL params are applied in mount/3; subsequent push_patch calls # hit this callback with the same params that our own push_patch # just wrote, so there's nothing further to reconcile here. {:noreply, socket} end @impl true def handle_event("select_band", %{"value" => band}, socket) do band = if is_binary(band), do: String.to_integer(band), else: band valid_times = Propagation.available_valid_times(band) selected_time = closest_to_now(valid_times) scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds) socket = schedule_preload_forecast(socket) socket = socket |> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time) |> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now())) |> MicrowavepropWeb.LiveStashGuard.stash() |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) |> push_event("update_band_info", %{band_info: band_info(band)}) |> push_timeline() |> patch_map_url() {:noreply, socket} end def handle_event("select_time", %{"time" => time_str}, socket) do case DateTime.from_iso8601(time_str) do {:ok, time, _} -> scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds) socket = socket |> assign(:selected_time, time) |> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now())) |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) |> patch_map_url() {:noreply, socket} _ -> {:noreply, socket} end end # Fast path: the client already has the preloaded hour's scores in its # local cache and only needs the server to remember which time is selected # (used later for point_detail and forecast state on reconnect). def handle_event("set_selected_time", %{"time" => time_str}, socket) do case DateTime.from_iso8601(time_str) do {:ok, time, _} -> socket = socket |> assign(:selected_time, time) |> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now())) |> patch_map_url() {:noreply, socket} _ -> {:noreply, socket} end end def handle_event("toggle_grid", _params, socket) do visible = !socket.assigns.grid_visible socket = socket |> assign(:grid_visible, visible) |> push_event("toggle_grid", %{visible: visible}) {:noreply, socket} end def handle_event("toggle_radar", _params, socket) do visible = !socket.assigns.radar_visible socket = socket |> assign(:radar_visible, visible) |> push_event("toggle_radar", %{visible: visible}) {:noreply, socket} end def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do band = socket.assigns.selected_band detail = Propagation.point_detail(band, lat, lon, socket.assigns.selected_time) forecast = Propagation.point_forecast(band, lat, lon) forecast_data = Enum.map(forecast, fn f -> %{time: DateTime.to_iso8601(f.valid_time), score: f.score} end) # Rain-scatter (NEXRAD cell overlay + scatter-dB readings in the detail # panel) is disabled for now — the live radar WMS toggle covers the # "is there rain?" question and the scatter-propagation feature needs # more work before it's useful. To re-enable: restore the :rain_scatter # payload key and the start_async/handle_async pair below. payload = if detail, do: Map.put(detail, :forecast, forecast_data), else: %{} {:noreply, push_event(socket, "point_detail", payload)} end def handle_event("map_bounds", bounds, socket) do # Leaflet fires `moveend` once per pan, but wheel-zoom can fire # 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)) # `on_timeout: :kill_task` + explicit filter on :ok keeps a slow NFS # read from cascading the whole stream's :timeout exit up into the # LiveView process. A timed-out forecast hour just drops from the # preload payload; the client re-requests it on demand. hours = forecast_times |> Task.async_stream( fn t -> %{ time: DateTime.to_iso8601(t), scores: Propagation.pack_scores(Propagation.scores_at(band, t, bounds)) } end, max_concurrency: 4, ordered: true, timeout: 10_000, on_timeout: :kill_task ) |> Enum.zip(forecast_times) |> Enum.flat_map(fn {{:ok, h}, _t} -> [h] {{:exit, reason}, t} -> Logger.error( "MapLive forecast preload task crashed: band=#{band} valid_time=#{inspect(t)} reason=#{inspect(reason)}" ) [] end) {:noreply, socket |> assign(:preload_forecast_timer, nil) |> push_event("preload_forecast", %{hours: hours})} end def handle_info({:propagation_updated, _valid_times}, socket) do band = socket.assigns.selected_band valid_times = Propagation.available_valid_times(band) # Stay on the same selected time if still available, else pick earliest selected_time = if socket.assigns.selected_time in valid_times do socket.assigns.selected_time else closest_to_now(valid_times) end # scores_at_fresh bypasses the cache and re-reads the .prop file so # the map reflects the newly written forecast hour even when the # ScoreCache still holds the previous chain's bytes. The propagation # update arrives via `propagation:updated` while the cache refresh # rides `propagation:cache`; the two topics deliver independently # so reading through the cache here can race against stale data. scores = if selected_time, do: Propagation.scores_at_fresh(band, selected_time, socket.assigns.bounds), else: [] socket = schedule_preload_forecast(socket) socket = socket |> assign(valid_times: valid_times, selected_time: selected_time) |> assign(:pipeline_status, PipelineStatus.current()) |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) |> push_timeline() {:noreply, socket} end def handle_info(:advance_now_cursor, socket) do Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms) if socket.assigns.tracking_now? do new_time = closest_to_now(socket.assigns.valid_times) if new_time && socket.assigns.selected_time && DateTime.after?(new_time, socket.assigns.selected_time) do scores = Propagation.scores_at(socket.assigns.selected_band, new_time, socket.assigns.bounds) socket = socket |> assign(:selected_time, new_time) |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) |> push_timeline() |> patch_map_url(replace: true) {:noreply, socket} else {:noreply, socket} end else {:noreply, socket} end end def handle_info(:refresh_pipeline_status, socket) do Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms) new_status = PipelineStatus.current() progress = if new_status.state == :running do socket.assigns[:pipeline_progress] end socket = socket |> assign(:pipeline_status, new_status) |> assign(:pipeline_progress, progress) {:noreply, socket} end def handle_info({:propagation_pipeline_progress, progress}, socket) do {:noreply, assign(socket, :pipeline_progress, progress)} end @impl true def handle_async(:initial_scores, {:ok, scores}, socket) do current = socket.assigns.initial_scores socket = socket |> assign(:initial_scores, AsyncResult.ok(current, scores)) |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) {:noreply, socket} end def handle_async(:initial_scores, {:exit, reason}, socket) do Logger.warning("Initial score fetch failed: #{inspect(reason)}") current = socket.assigns.initial_scores {:noreply, assign(socket, :initial_scores, AsyncResult.failed(current, reason))} end def handle_async(:viewshed, {:ok, result}, socket) do points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end) Logger.info("Viewshed complete: #{length(points)} boundary points") socket = push_event(socket, "viewshed_result", %{ origin: result.origin, points: points }) {:noreply, socket} end def handle_async(:viewshed, {:exit, reason}, socket) do Logger.warning("Viewshed computation failed: #{inspect(reason)}") {:noreply, socket} end defp push_timeline(socket) do times = Enum.map(socket.assigns.valid_times, fn t -> %{time: DateTime.to_iso8601(t), label: format_time_label(t)} end) selected = if socket.assigns.selected_time, do: DateTime.to_iso8601(socket.assigns.selected_time) push_event(socket, "update_timeline", %{times: times, selected: selected}) end defp format_time_label(dt) do # Show as "HH:MM" in UTC Calendar.strftime(dt, "%H:%M") end @doc false # Render the small "Data from HH:MM UTC · Nh ago" indicator that sits # above the pipeline status chip. Tells the user at a glance which # valid_time the map is currently showing when the bottom timeline # isn't visible. @spec format_data_timestamp(DateTime.t() | nil) :: String.t() def format_data_timestamp(nil), do: "No data" def format_data_timestamp(%DateTime{} = dt) do diff_minutes = DateTime.diff(dt, DateTime.utc_now(), :minute) relative = format_relative_minutes(diff_minutes) "Data from #{Calendar.strftime(dt, "%H:%M")} UTC · #{relative}" end defp format_relative_minutes(diff) when diff >= -30 and diff <= 30, do: "now" defp format_relative_minutes(diff) when diff > 30 do # Future hours = div(diff + 30, 60) "+#{hours}h" end defp format_relative_minutes(diff) do # Past hours = div(-diff + 30, 60) "#{hours}h ago" end # --- URL param plumbing ----------------------------------------------- # The JS hook piggybacks map center + zoom onto the bounds event # so the URL can carry a shareable view. Old payloads (no center) # should still work — fall back to keeping whatever we had. defp maybe_assign_center_zoom(socket, %{"center_lat" => lat, "center_lon" => lon, "zoom" => zoom}) when is_number(lat) and is_number(lon) and is_number(zoom) do assign(socket, current_center: %{lat: lat, lon: lon}, current_zoom: trunc(zoom)) end defp maybe_assign_center_zoom(socket, _), do: socket # Leaflet fires `moveend` on every pan + zoom, so bounds events arrive # in bursts during interactive map use. Each :preload_forecast delivers # 18 forecast-hour score lists filtered to the current viewport (up to # ~90k cells per hour at full CONUS view), so firing it per moveend # produced an 18× read + large websocket payload per pan. Debounce to # the trailing edge: the user has stopped moving for 750 ms before we # pay the preload cost. @preload_debounce_ms 750 defp schedule_preload_forecast(%{assigns: %{preload_forecast_timer: ref}} = socket) when is_reference(ref) do _ = Process.cancel_timer(ref) do_schedule_preload_forecast(socket) end defp schedule_preload_forecast(socket), do: do_schedule_preload_forecast(socket) defp do_schedule_preload_forecast(socket) do ref = Process.send_after(self(), :preload_forecast, @preload_debounce_ms) assign(socket, :preload_forecast_timer, ref) end # Debounce the `update_scores` repaint so a wheel-zoom burst (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: 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"""