From aebf3911ce0eadc81ef4f13abdc1f7350128d91c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 20 Apr 2026 16:51:51 -0500 Subject: [PATCH] perf(map): debounce preload_forecast + single-pass score filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/propagation/score_cache.ex | 14 +++++--- lib/microwaveprop_web/live/map_live.ex | 38 +++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/lib/microwaveprop/propagation/score_cache.ex b/lib/microwaveprop/propagation/score_cache.ex index 9e0edc4d..8e2cb3be 100644 --- a/lib/microwaveprop/propagation/score_cache.ex +++ b/lib/microwaveprop/propagation/score_cache.ex @@ -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 diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 518af254..38ea78ed 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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