diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 3d57ce3a..9f7b6cfb 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.MapLive do alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.PipelineStatus alias Microwaveprop.Terrain.Viewshed + alias Phoenix.LiveView.AsyncResult require Logger @@ -70,7 +71,25 @@ defmodule MicrowavepropWeb.MapLive do visitor = visitor_location(session) bounds = bounds_around(center) - initial_scores = Propagation.scores_at(selected_band, selected_time, bounds) + + # 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!(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 @@ -85,7 +104,8 @@ defmodule MicrowavepropWeb.MapLive do page_title: "Propagation Prediction Map", bands: bands, selected_band: selected_band, - initial_scores_json: Jason.encode!(initial_scores), + 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()), @@ -342,6 +362,22 @@ defmodule MicrowavepropWeb.MapLive do {: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(:preload_forecast, socket) do band = socket.assigns.selected_band @@ -444,6 +480,23 @@ defmodule MicrowavepropWeb.MapLive do 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: 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") @@ -782,6 +835,37 @@ defmodule MicrowavepropWeb.MapLive do > + <%!-- 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> +