diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index d2366bd6..c8b4cbf7 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -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 ~4–5× 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 diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index c5fae1b2..e3f623ee 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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 + # 5–10 per second. Without a debounce, each event kicked off a + # `scores_at` disk read + ~20–100 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 (5–10 + # 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 diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index 32ff1f85..15cdb736 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -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)