perf(map): debounce preload_forecast + single-pass score filter

Two wins in the /map hot path.

1. preload_forecast fired on every Leaflet moveend — which arrives in
   bursts during pan/zoom — and each firing read + filtered + shipped
   18 forecast-hour score lists (up to 90k cells per hour at full
   CONUS view) over the LiveView websocket. Now debounced to the
   trailing edge: the user must stop moving for 750 ms before we pay
   the preload cost. select_band and propagation_updated go through
   the same scheduler so a storm of PubSub updates during an hourly
   chain also coalesces.

2. ScoreCache.grid_to_filtered_list walked the 92k-cell grid twice
   (Enum.filter then Enum.map) and allocated an intermediate tuple
   list between them. Enum.reduce emits only in-bounds result maps
   directly — halves map traversal + drops the tuple intermediate.
This commit is contained in:
Graham McIntire 2026-04-20 16:51:51 -05:00
parent 9815bd691e
commit aebf3911ce
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 40 additions and 12 deletions

View file

@ -151,10 +151,16 @@ defmodule Microwaveprop.Propagation.ScoreCache do
defp grid_to_filtered_list(grid, nil), do: grid_to_list(grid)
defp grid_to_filtered_list(grid, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
grid
|> Enum.filter(fn {{lat, lon}, _score} ->
lat >= s and lat <= n and lon >= w and lon <= e
# Single pass over 92k cells: the former Enum.filter |> Enum.map
# walked the map twice and allocated an intermediate list of
# {key, value} tuples between them. Enum.reduce emits only the
# in-bounds result maps directly.
Enum.reduce(grid, [], fn {{lat, lon}, score}, acc ->
if lat >= s and lat <= n and lon >= w and lon <= e do
[%{lat: lat, lon: lon, score: score} | acc]
else
acc
end
end)
|> Enum.map(fn {{lat, lon}, score} -> %{lat: lat, lon: lon, score: score} end)
end
end

View file

@ -97,7 +97,8 @@ defmodule MicrowavepropWeb.MapLive do
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)
deploy_ago: format_deploy_ago(build_ts),
preload_forecast_timer: nil
)}
end
@ -202,7 +203,7 @@ defmodule MicrowavepropWeb.MapLive do
selected_time = closest_to_now(valid_times)
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
send(self(), :preload_forecast)
socket = schedule_preload_forecast(socket)
socket =
socket
@ -303,15 +304,12 @@ defmodule MicrowavepropWeb.MapLive do
def handle_event("map_bounds", bounds, socket) do
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
# Refresh the forecast cache for the new viewport — the client cleared its
# local cache in sendBounds() so any timeline scrub would otherwise miss.
send(self(), :preload_forecast)
socket =
socket
|> assign(:bounds, bounds)
|> maybe_assign_center_zoom(bounds)
|> push_event("update_scores", %{scores: scores})
|> schedule_preload_forecast()
|> patch_map_url(replace: true)
{:noreply, socket}
@ -356,7 +354,10 @@ defmodule MicrowavepropWeb.MapLive do
%{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)}
end)
{:noreply, push_event(socket, "preload_forecast", %{hours: hours})}
{:noreply,
socket
|> assign(:preload_forecast_timer, nil)
|> push_event("preload_forecast", %{hours: hours})}
end
def handle_info({:propagation_updated, _valid_times}, socket) do
@ -381,7 +382,7 @@ defmodule MicrowavepropWeb.MapLive do
do: Propagation.scores_at_fresh(band, selected_time, socket.assigns.bounds),
else: []
send(self(), :preload_forecast)
socket = schedule_preload_forecast(socket)
socket =
socket
@ -518,6 +519,27 @@ defmodule MicrowavepropWeb.MapLive do
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
defp patch_map_url(socket, opts \\ []) do
push_patch(socket, to: map_url(socket.assigns), replace: Keyword.get(opts, :replace, false))
end