perf(maps): debounce map_bounds repaint + parallelize point_forecast

Two wins that directly shrink /map and /weather latency under
interactive use:

**Debounce the moveend → repaint path (150ms).** Leaflet fires
`moveend` once per pan but 5–10× per second during a wheel-zoom.
Previously each one ran a full `scores_at` (or `weather_grid_at`)
read + a ~20–100KB websocket push. Now they coalesce into a single
trailing-edge flush via `schedule_bounds_update` (and the
/weather twin `schedule_weather_flush`) — the same pattern we
already use for forecast preload. 150ms is short enough that the
user doesn't perceive the pause; >90% of the burst disappears.

**Parallelize point_forecast across 4 tasks.** The sparkline that
pops up on point click was reading up to 18 `.prop` files
sequentially off NFS. Each read is cheap (pread one byte at
row*cols+col) but RTT-dominated. `Task.async_stream` with
max_concurrency=4, ordered=true brings 18-file wall time from
~50ms down to ~15ms on NFS without changing the public API.

No test changes: existing map_bounds test only asserts the bounds
assign lands, which the debounced path still does synchronously.
This commit is contained in:
Graham McIntire 2026-04-21 17:34:31 -05:00
parent 9780d2b477
commit 4d07f89260
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 94 additions and 19 deletions

View file

@ -389,11 +389,24 @@ defmodule Microwaveprop.Propagation do
# the chart never falls behind the main-map timeline (which also
# reads the disk). The cache is still consulted per-hour for a
# fast score lookup; a miss falls through to the file.
# Fan the per-hour disk lookups across 4 tasks. Each ScoresFile
# read_point is an NFS stat + pread of ~100 bytes (keyed byte at
# row*cols+col), so the ceiling is NFS RTT × number of hours —
# sequential ran ~45× the wall time of the slowest read.
band_mhz
|> ScoresFile.list_valid_times()
|> forecast_window(now)
|> Enum.map(&point_forecast_entry(band_mhz, &1, snapped_lat, snapped_lon))
|> Enum.reject(&is_nil/1)
|> Task.async_stream(
&point_forecast_entry(band_mhz, &1, snapped_lat, snapped_lon),
max_concurrency: 4,
ordered: true,
timeout: 5_000
)
|> Enum.flat_map(fn
{:ok, nil} -> []
{:ok, entry} -> [entry]
{:exit, _} -> []
end)
end)
end

View file

@ -119,7 +119,8 @@ defmodule MicrowavepropWeb.MapLive do
radar_visible: false,
deploy_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"),
deploy_ago: format_deploy_ago(build_ts),
preload_forecast_timer: nil
preload_forecast_timer: nil,
bounds_flush_timer: nil
)}
end
@ -323,13 +324,17 @@ defmodule MicrowavepropWeb.MapLive do
end
def handle_event("map_bounds", bounds, socket) do
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
# Leaflet fires `moveend` once per pan, but wheel-zoom can fire
# 510 per second. Without a debounce, each event kicked off a
# `scores_at` disk read + ~20100 KB websocket push. Debounce the
# score repaint to ~150ms — short enough that pans feel live
# (user releases → <1 frame latency) but absorbs bursts. The URL
# patch stays immediate so share-link state keeps up.
socket =
socket
|> assign(:bounds, bounds)
|> maybe_assign_center_zoom(bounds)
|> push_event("update_scores", %{scores: scores})
|> schedule_bounds_update()
|> schedule_preload_forecast()
|> patch_map_url(replace: true)
@ -379,6 +384,20 @@ defmodule MicrowavepropWeb.MapLive do
end
@impl true
def handle_info(:flush_bounds, socket) do
scores =
Propagation.scores_at(
socket.assigns.selected_band,
socket.assigns.selected_time,
socket.assigns.bounds
)
{:noreply,
socket
|> assign(:bounds_flush_timer, nil)
|> push_event("update_scores", %{scores: scores})}
end
def handle_info(:preload_forecast, socket) do
band = socket.assigns.selected_band
bounds = socket.assigns.bounds
@ -605,6 +624,24 @@ defmodule MicrowavepropWeb.MapLive do
assign(socket, :preload_forecast_timer, ref)
end
# Debounce the `update_scores` repaint so a wheel-zoom burst (510
# moveend events per second) doesn't run 10× `scores_at` disk reads
# or push 10× the wire payload. 150 ms is short enough that the
# user doesn't perceive it as lag.
@bounds_flush_debounce_ms 150
defp schedule_bounds_update(%{assigns: %{bounds_flush_timer: ref}} = socket) when is_reference(ref) do
_ = Process.cancel_timer(ref)
do_schedule_bounds_update(socket)
end
defp schedule_bounds_update(socket), do: do_schedule_bounds_update(socket)
defp do_schedule_bounds_update(socket) do
ref = Process.send_after(self(), :flush_bounds, @bounds_flush_debounce_ms)
assign(socket, :bounds_flush_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

View file

@ -177,7 +177,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
bounds: @initial_bounds,
grid_visible: false,
radar_visible: false
radar_visible: false,
weather_flush_timer: nil
)}
end
@ -216,18 +217,15 @@ defmodule MicrowavepropWeb.WeatherMapLive do
end
def handle_event("map_bounds", bounds, socket) do
data =
case socket.assigns.valid_time do
nil -> Weather.latest_weather_grid(bounds)
vt -> Weather.weather_grid_at(vt, bounds)
end
socket =
socket
|> assign(:bounds, bounds)
|> push_event("update_weather", %{data: data})
{:noreply, socket}
# Debounce the repaint. Cache miss on `weather_grid_at` runs a
# derive pass over hrrr_profiles rows inside the viewport — full
# CONUS is hundreds of KB of JSON and a non-trivial Ecto query.
# Running it on every moveend burst during a wheel-zoom was
# visibly laggy. 150ms matches /map's throttle.
{:noreply,
socket
|> assign(:bounds, bounds)
|> schedule_weather_flush()}
end
def handle_event("select_time", %{"time" => time_str}, socket) do
@ -257,6 +255,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
end
@impl true
def handle_info(:flush_weather_bounds, socket) do
data =
case socket.assigns.valid_time do
nil -> Weather.latest_weather_grid(socket.assigns.bounds)
vt -> Weather.weather_grid_at(vt, socket.assigns.bounds)
end
{:noreply,
socket
|> assign(:weather_flush_timer, nil)
|> push_event("update_weather", %{data: data})}
end
def handle_info({:weather_updated, _valid_time}, socket) do
valid_times = Weather.available_weather_valid_times()
@ -310,6 +321,20 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@group_order ["Surface", "Upper Air", "Ducting"]
@weather_flush_debounce_ms 150
defp schedule_weather_flush(%{assigns: %{weather_flush_timer: ref}} = socket) when is_reference(ref) do
_ = Process.cancel_timer(ref)
do_schedule_weather_flush(socket)
end
defp schedule_weather_flush(socket), do: do_schedule_weather_flush(socket)
defp do_schedule_weather_flush(socket) do
ref = Process.send_after(self(), :flush_weather_bounds, @weather_flush_debounce_ms)
assign(socket, :weather_flush_timer, ref)
end
defp group_layers(layers) do
layers
|> Enum.group_by(& &1.group)