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.BandConfig
|
||||||
alias Microwaveprop.Propagation.PipelineStatus
|
alias Microwaveprop.Propagation.PipelineStatus
|
||||||
alias Microwaveprop.Terrain.Viewshed
|
alias Microwaveprop.Terrain.Viewshed
|
||||||
|
alias Phoenix.LiveView.AsyncResult
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -70,7 +71,25 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
|
|
||||||
visitor = visitor_location(session)
|
visitor = visitor_location(session)
|
||||||
bounds = bounds_around(center)
|
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
|
# preload_forecast runs on the first map_bounds event instead of
|
||||||
# here — mount only knows a hardcoded fallback bounding box, so
|
# here — mount only knows a hardcoded fallback bounding box, so
|
||||||
|
|
@ -85,7 +104,8 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
page_title: "Propagation Prediction Map",
|
page_title: "Propagation Prediction Map",
|
||||||
bands: bands,
|
bands: bands,
|
||||||
selected_band: selected_band,
|
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,
|
valid_times: valid_times,
|
||||||
selected_time: selected_time,
|
selected_time: selected_time,
|
||||||
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
|
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
|
||||||
|
|
@ -342,6 +362,22 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_info(:preload_forecast, socket) do
|
def handle_info(:preload_forecast, socket) do
|
||||||
band = socket.assigns.selected_band
|
band = socket.assigns.selected_band
|
||||||
|
|
@ -444,6 +480,23 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
def handle_async(:viewshed, {:ok, result}, socket) do
|
||||||
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
|
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
|
||||||
Logger.info("Viewshed complete: #{length(points)} boundary points")
|
Logger.info("Viewshed complete: #{length(points)} boundary points")
|
||||||
|
|
@ -782,6 +835,37 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
>
|
>
|
||||||
</div>
|
</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 --%>
|
<%!-- Mobile-only floating controls --%>
|
||||||
<div
|
<div
|
||||||
id="map-controls"
|
id="map-controls"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
defmodule MicrowavepropWeb.MapLiveTest do
|
defmodule MicrowavepropWeb.MapLiveTest do
|
||||||
use MicrowavepropWeb.ConnCase, async: false
|
use MicrowavepropWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.ConnTest, only: [get: 2]
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Microwaveprop.Propagation
|
alias Microwaveprop.Propagation
|
||||||
alias Microwaveprop.Propagation.ScoreCache
|
alias Microwaveprop.Propagation.ScoreCache
|
||||||
|
alias Phoenix.LiveView.AsyncResult
|
||||||
alias Phoenix.LiveView.Utils
|
alias Phoenix.LiveView.Utils
|
||||||
|
|
||||||
describe "mount" do
|
describe "mount" do
|
||||||
|
|
@ -52,6 +54,63 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
||||||
end
|
end
|
||||||
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
|
describe "CONUS-only banner" do
|
||||||
@banner_copy "This data set currently covers the continental United States only."
|
@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")
|
{: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
|
# Simulate a new forecast-hour compute: rewrite the .ntms file on
|
||||||
# disk to the updated score, but do *not* touch the cache. This
|
# disk to the updated score, but do *not* touch the cache. This
|
||||||
# reproduces the race where the cache_refresh message has not yet
|
# reproduces the race where the cache_refresh message has not yet
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue