feat(map): render chrome immediately, load initial scores async
Two-stage mount splits the propagation map's first paint: the static HTTP render still fetches scores synchronously so SEO/noscript shipping gets real data baked into `data-scores`, but the websocket-connected mount defers the potentially-slow fetch to `start_async/3`. Chrome paints immediately; scores stream in via an `update_scores` push_event once `handle_async(:initial_scores, ...)` resolves. A small overlay (spinner on `:loading`, retry button on `:failed`) uses `<.async_result>` so users can see state and recover from a transient fetch error.
This commit is contained in:
parent
52a32032a3
commit
ef4e63aef4
2 changed files with 150 additions and 2 deletions
|
|
@ -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
|
|||
>
|
||||
</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"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
defmodule MicrowavepropWeb.MapLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.ConnTest, only: [get: 2]
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.ScoreCache
|
||||
alias Phoenix.LiveView.AsyncResult
|
||||
alias Phoenix.LiveView.Utils
|
||||
|
||||
describe "mount" do
|
||||
|
|
@ -52,6 +54,63 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "async initial scores" do
|
||||
setup do
|
||||
ScoreCache.clear()
|
||||
on_exit(fn -> ScoreCache.clear() end)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "disconnected HTTP render returns 200 with a data-scores payload", %{conn: conn} do
|
||||
valid_time =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.truncate(:second)
|
||||
|> Map.put(:minute, 0)
|
||||
|> Map.put(:second, 0)
|
||||
|
||||
Propagation.replace_scores(
|
||||
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 42, factors: nil}],
|
||||
valid_time
|
||||
)
|
||||
|
||||
conn = get(conn, ~p"/map")
|
||||
|
||||
assert conn.status == 200
|
||||
body = Phoenix.ConnTest.html_response(conn, 200)
|
||||
# data-scores is baked into the static HTTP render so first paint
|
||||
# ships real data even without a websocket connection.
|
||||
assert body =~ ~s(data-scores=)
|
||||
refute body =~ ~s(data-scores="[]")
|
||||
end
|
||||
|
||||
test "connected mount resolves @initial_scores to an :ok AsyncResult", %{conn: conn} do
|
||||
valid_time =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.truncate(:second)
|
||||
|> Map.put(:minute, 0)
|
||||
|> Map.put(:second, 0)
|
||||
|
||||
Propagation.replace_scores(
|
||||
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 77, factors: nil}],
|
||||
valid_time
|
||||
)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
# The websocket mount kicks off start_async(:initial_scores, ...) and
|
||||
# pushes `update_scores` once the fetch resolves.
|
||||
assert_push_event(lv, "update_scores", %{scores: scores})
|
||||
assert is_list(scores)
|
||||
|
||||
# Force a render so the AsyncResult assignment is flushed, then
|
||||
# inspect the socket's assigns directly.
|
||||
_ = render(lv)
|
||||
async_result = :sys.get_state(lv.pid).socket.assigns.initial_scores
|
||||
|
||||
assert %AsyncResult{ok?: true} = async_result
|
||||
end
|
||||
end
|
||||
|
||||
describe "CONUS-only banner" do
|
||||
@banner_copy "This data set currently covers the continental United States only."
|
||||
|
||||
|
|
@ -396,6 +455,11 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
# The connected mount kicks off an async initial-score fetch that
|
||||
# emits its own `update_scores` push_event. Drain it here so the
|
||||
# assertion below observes the propagation_updated delivery only.
|
||||
assert_push_event(lv, "update_scores", %{scores: _initial_scores})
|
||||
|
||||
# Simulate a new forecast-hour compute: rewrite the .ntms file on
|
||||
# disk to the updated score, but do *not* touch the cache. This
|
||||
# reproduces the race where the cache_refresh message has not yet
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue