From 6a90d153ca4dc75827dd76edd249525338c7bea7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 29 Apr 2026 14:27:20 -0500 Subject: [PATCH] perf(map): /scores/cells binary endpoint, client-side fetch + band/time prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same overlay refactor we did for /weather to the propagation map. Score data used to flow as JSON via LiveView push_event (update_scores + the 18-hour preload_forecast batch); switching bands or scrubbing time meant a synchronous server roundtrip + ~MB JSON push per change. Now: 1. New /scores/cells endpoint returns a "PSCR" binary cell-pack — columnar f32 lats/lons + u8 scores, ~3-4× smaller than JSON and parsed via DataView + typed-array views with no JSON.parse. 2. JS hook owns score fetching. mount/moveend/band/time changes trigger HTTP fetches against /scores/cells. After the initial paint, every other forecast hour for the current band is fetched in parallel in the background — timeline scrubs after the prefetch finishes are pure cache hits with no network at all. 3. LiveView no longer pushes scores. select_band emits update_band_info with band_mhz so the client knows what to refetch; select_time and set_selected_time only update server-side state (URL, point detail bbox). map_bounds, propagation_updated, and advance_now_cursor all skip the score push — pack_scores, schedule_bounds_update, schedule_preload_forecast, the :flush_bounds and :preload_forecast handle_info clauses, and the start_async :initial_scores lifecycle (incl. retry_initial_scores event) are all deleted. Net result: instant band toggles after the first paint, no LiveView WebSocket payloads larger than the timeline metadata, and ~370 fewer lines in MapLive. --- assets/js/propagation_map_hook.ts | 224 +++++++++++---- lib/microwaveprop/propagation.ex | 17 -- .../controllers/scores_controller.ex | 102 +++++++ lib/microwaveprop_web/live/map_live.ex | 255 ++---------------- lib/microwaveprop_web/router.ex | 1 + .../controllers/scores_controller_test.exs | 79 ++++++ test/microwaveprop_web/live/map_live_test.exs | 106 ++------ 7 files changed, 391 insertions(+), 393 deletions(-) create mode 100644 lib/microwaveprop_web/controllers/scores_controller.ex create mode 100644 test/microwaveprop_web/controllers/scores_controller_test.exs diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index bf3846af..c4dc5005 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -4,6 +4,29 @@ import { updateGridOverlay } from "./maidenhead_grid" const KM2MI = 0.621371 +// Decode the binary "PSCR" cell-pack served by /scores/cells. +// See lib/microwaveprop_web/controllers/scores_controller.ex for the +// authoritative format spec. +function decodeScorePack(buf: ArrayBuffer): ScorePoint[] { + const view = new DataView(buf) + const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)) + if (magic !== "PSCR") throw new Error(`bad magic: ${magic}`) + const version = view.getUint8(4) + if (version !== 1) throw new Error(`unsupported version: ${version}`) + const cellCount = view.getUint32(8, true) + if (cellCount === 0) return [] + + const lats = new Float32Array(buf, 12, cellCount) + const lons = new Float32Array(buf, 12 + cellCount * 4, cellCount) + const scores = new Uint8Array(buf, 12 + cellCount * 8, cellCount) + + const out: ScorePoint[] = new Array(cellCount) + for (let i = 0; i < cellCount; i++) { + out[i] = [lats[i], lons[i], scores[i]] + } + return out +} + function kmRangeToMi(lowKm: number, highKm: number): string { const lowMi = Math.round(lowKm * KM2MI) const highMi = Math.round(highKm * KM2MI) @@ -163,15 +186,23 @@ interface PropagationMapHook extends ViewHook { reachPolygon: L.Polygon | null timelineEl: HTMLElement | null timelineData: TimelineEntry[] + selectedBand: number selectedTime: string | null - forecastCache: Map + // Cache key: `${band}|${time}|${roundedBoundsKey}` → ScorePoint[] + scoresCache: Map + scoresBoundsKey: string + inflightScoresAbort: AbortController | null playbackTimer: ReturnType | null + moveDebounce: ReturnType | null visibilityHandler: (() => void) | null showDetailPanel(this: PropagationMapHook): void hideDetailPanel(this: PropagationMapHook): void redrawReachPolygon(this: PropagationMapHook): void renderScores(this: PropagationMapHook, scores: ScorePoint[]): void + fetchScores(this: PropagationMapHook, band: number, time: string | null, signal?: AbortSignal): Promise + loadAndRender(this: PropagationMapHook, opts?: { force?: boolean }): Promise + prefetchForecast(this: PropagationMapHook): Promise drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer): void interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null scoreColorRGB(this: PropagationMapHook, score: number): RGB @@ -715,8 +746,12 @@ export const PropagationMap: Record & { this.scoreOverlay = null this.scores = [] - this.forecastCache = new Map() + this.scoresCache = new Map() + this.scoresBoundsKey = "" + this.inflightScoresAbort = null this.playbackTimer = null + this.moveDebounce = null + this.selectedBand = parseInt(this.el.dataset.selectedBand || "10000", 10) this.colorScale = [ { min: 80, r: 0, g: 255, b: 163 }, @@ -726,50 +761,34 @@ export const PropagationMap: Record & { { min: 0, r: 255, g: 79, b: 79 } ] - // Render pre-loaded scores immediately (no round-trip needed) - const initialScores: ScorePoint[] = JSON.parse(this.el.dataset.scores || "[]") - if (initialScores.length > 0) { - this.renderScores(initialScores) - } - - this.handleEvent("update_scores", ({ scores }: { scores: ScorePoint[] }) => { - topbar.hide() - this.renderScores(scores) - if (this.selectedTime) this.forecastCache.set(this.selectedTime, scores) - - // Reach polygon is a pure function of scoreGrid + clickedLatLng, so we - // can refresh it immediately client-side without waiting on the server - // round-trip. Handles the case where the new band has no reach at all - // (hull empty → stale polygon from the old band is cleared). - this.redrawReachPolygon() - - // If the detail panel is open, refresh its factors/forecast for the - // new band via the server. The polygon is already up-to-date above. - if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") { - this.pushEvent("point_detail", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] }) - } - }) - - // Preloaded forecast hours arrive after mount/band change — stash them so - // future timeline scrubs can render instantly from the local cache. - this.handleEvent("preload_forecast", ({ hours }: { hours: { time: string; scores: ScorePoint[] }[] }) => { - for (const h of hours) { - this.forecastCache.set(h.time, h.scores) - } - }) + // The score-data flow used to be a LiveView push_event from the + // server (`update_scores` + `preload_forecast`). Now the client + // pulls scores from /scores/cells as binary cell-packs: + // • mount + reconnect + bounds change → loadAndRender for the + // current band/time, then prefetchForecast for the rest of the + // timeline in the background. + // • band change → server pushes update_band_info; client triggers + // loadAndRender + prefetch. + // • time change → if the cache has it, render instantly; else + // fetch and render. this.bandInfo = JSON.parse(this.el.dataset.bandInfo || "{}") this.rangeCircles = L.layerGroup().addTo(this.map) - this.handleEvent("update_band_info", ({ band_info }: { band_info: BandInfo }) => { + this.handleEvent("update_band_info", ({ band_info, band_mhz }: { band_info: BandInfo; band_mhz?: number }) => { this.bandInfo = band_info + // Server tells us the new band when select_band lands. Update + // local state and refetch scores via HTTP. + if (typeof band_mhz === "number" && band_mhz !== this.selectedBand) { + this.selectedBand = band_mhz + // Discard cache entries for the previous band — different keys + // wouldn't collide but they bloat the map for no benefit. + this.scoresCache.clear() + void this.loadAndRender({ force: true }) + } // Viewshed depends on freq + score-derived range, so recompute it when // the band changes or antenna height moves if a point is still selected. - // Also re-request point_detail so the popup's range-estimate line picks - // up the updated typical/extended/exceptional ranges. update_scores - // (on band change) already refreshes the polygon/heatmap; for - // antenna-height changes the polygon is height-independent today. if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") { this.viewshedLoading = true this.pushEvent("compute_viewshed", { @@ -1027,9 +1046,15 @@ export const PropagationMap: Record & { clearInterval(this.playbackTimer) this.playbackTimer = null } + const timeChanged = selected !== this.selectedTime this.timelineData = times this.selectedTime = selected this.renderTimeline() + if (timeChanged) { + // The cache may already have this time (forecast prefetch hit); + // loadAndRender will use it and skip the network. + void this.loadAndRender() + } }) // Prevent timeline from eating map clicks @@ -1477,19 +1502,13 @@ export const PropagationMap: Record & { this.selectedTime = time this.renderTimeline() - const cached = this.forecastCache.get(time) - if (cached) { - // Fast path — render instantly from preloaded cache, just inform - // the server of the new selected time for state tracking. - this.renderScores(cached) - this.redrawReachPolygon() - this.pushEvent("set_selected_time", { time }) - } else { - // Cache miss — fall back to server roundtrip via the existing - // update_scores path (which also redraws the polygon). - topbar.show() - this.pushEvent("select_time", { time }) - } + // Inform the server of the selected time for URL/state tracking + // (point_detail and forecast lookups still need it server-side). + this.pushEvent("set_selected_time", { time }) + + // loadAndRender uses the local cache when possible (forecast + // prefetch hit) and falls back to a single HTTP fetch otherwise. + void this.loadAndRender() }, startPlayback(this: PropagationMapHook) { @@ -1551,11 +1570,12 @@ export const PropagationMap: Record & { }, sendBounds(this: PropagationMapHook) { - topbar.show() - // Bounds changed — preloaded forecast hours are now for stale viewport. - // Drop them so timeline scrubs re-fetch for the new viewport. - this.forecastCache.clear() - // Pad bounds by ~0.5° so Leaflet edge tiles always have data to render + // The viewport changed: drop the per-viewport scores cache and + // refetch for the current band/time. Server is told about the new + // bounds (and center/zoom) so URL patching and reconnects keep + // working, but it no longer pushes scores back — the client pulls + // them from /scores/cells. + this.scoresCache.clear() const b = this.map.getBounds().pad(0.1) const c = this.map.getCenter() this.pushEvent("map_bounds", { @@ -1567,6 +1587,98 @@ export const PropagationMap: Record & { center_lon: c.lng, zoom: this.map.getZoom() }) + void this.loadAndRender({ force: true }) + }, + + async fetchScores(this: PropagationMapHook, band: number, time: string | null, signal?: AbortSignal): Promise { + const b = this.map.getBounds().pad(0.1) + const south = b.getSouth().toFixed(4) + const north = b.getNorth().toFixed(4) + const west = b.getWest().toFixed(4) + const east = b.getEast().toFixed(4) + + let url = `/scores/cells?band=${band}&south=${south}&north=${north}&west=${west}&east=${east}` + if (time) url += `&time=${encodeURIComponent(time)}` + + const resp = await fetch(url, { signal }) + if (!resp.ok) throw new Error(`scores fetch failed: ${resp.status}`) + const buf = await resp.arrayBuffer() + return decodeScorePack(buf) + }, + + async loadAndRender(this: PropagationMapHook, opts: { force?: boolean } = {}): Promise { + if (!this.selectedTime) return + + const b = this.map.getBounds().pad(0.1) + const boundsKey = `${b.getSouth().toFixed(2)},${b.getNorth().toFixed(2)},${b.getWest().toFixed(2)},${b.getEast().toFixed(2)}` + if (boundsKey !== this.scoresBoundsKey) { + this.scoresBoundsKey = boundsKey + // New viewport — drop any cache entries from the old viewport. + this.scoresCache.clear() + } + + const cacheKey = `${this.selectedBand}|${this.selectedTime}|${boundsKey}` + const cached = this.scoresCache.get(cacheKey) + if (cached && !opts.force) { + this.renderScores(cached) + this.redrawReachPolygon() + void this.prefetchForecast() + return + } + + if (this.inflightScoresAbort) this.inflightScoresAbort.abort() + this.inflightScoresAbort = new AbortController() + + topbar.show() + try { + const scores = await this.fetchScores(this.selectedBand, this.selectedTime, this.inflightScoresAbort.signal) + // Stale-response guard: if anything moved/switched while this + // fetch was in flight, ignore it. + if (cacheKey !== `${this.selectedBand}|${this.selectedTime}|${this.scoresBoundsKey}`) return + this.scoresCache.set(cacheKey, scores) + this.renderScores(scores) + this.redrawReachPolygon() + topbar.hide() + void this.prefetchForecast() + } catch (e) { + const err = e as Error + if (err.name === "AbortError") return + console.warn("scores fetch failed", err) + topbar.hide() + } + }, + + async prefetchForecast(this: PropagationMapHook): Promise { + if (this.timelineData.length <= 1) return + const band = this.selectedBand + const boundsKey = this.scoresBoundsKey + + const others = this.timelineData + .map(t => t.time) + .filter(t => t !== this.selectedTime && !this.scoresCache.has(`${band}|${t}|${boundsKey}`)) + + if (others.length === 0) return + + // Fire all prefetches in parallel — HTTP/2 multiplexes them and + // the server's ScoreCache + ScoresFile mmap make per-time reads + // ~ms after the first one warms NFS cache. + await Promise.all(others.map(async time => { + const cacheKey = `${band}|${time}|${boundsKey}` + // Race protection: another loadAndRender may have already filled + // this entry. Skip in that case. + if (this.scoresCache.has(cacheKey)) return + try { + const scores = await this.fetchScores(band, time) + // Drop the response if the viewport changed under us. + if (this.scoresBoundsKey !== boundsKey || this.selectedBand !== band) return + this.scoresCache.set(cacheKey, scores) + } catch (e) { + const err = e as Error + if (err.name === "AbortError") return + // Prefetch failures are non-fatal — log and move on. + console.warn(`forecast prefetch failed for ${time}`, err) + } + })) }, reconnected(this: PropagationMapHook) { diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 32710eae..c1e2db29 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -377,23 +377,6 @@ defmodule Microwaveprop.Propagation do end) end - @doc """ - Pack a list of `%{lat, lon, score}` into `[[lat, lon, score], ...]` - for transmission to the browser. Repeating the three JSON keys for - every one of ~95k cells added ~50% to the wire payload; the flat - array representation trims ~40–45% of the bytes without changing - the information content. Browser-side, the typed `ScorePoint` - tuple is `[lat, lon, score]` and `ScoreGrid.put` takes positional - args, so the client decoder is also one allocation lighter per - point. - """ - @spec pack_scores([%{lat: float(), lon: float(), score: non_neg_integer()}]) :: [ - [number()] - ] - def pack_scores(scores) when is_list(scores) do - Enum.map(scores, fn %{lat: lat, lon: lon, score: score} -> [lat, lon, score] end) - end - @doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)." @spec latest_scores(non_neg_integer(), %{optional(String.t()) => float()} | nil) :: [%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}] diff --git a/lib/microwaveprop_web/controllers/scores_controller.ex b/lib/microwaveprop_web/controllers/scores_controller.ex new file mode 100644 index 00000000..20695d30 --- /dev/null +++ b/lib/microwaveprop_web/controllers/scores_controller.ex @@ -0,0 +1,102 @@ +defmodule MicrowavepropWeb.ScoresController do + @moduledoc """ + HTTP endpoint serving propagation score grids in a compact binary + cell-pack format. Replaces the old `update_scores` LiveView push for + the /map overlay so the client can fetch directly (cacheable, doesn't + block the LiveView channel) and decode via DataView + typed-array + views without a JSON parse. + """ + use MicrowavepropWeb, :controller + + alias Microwaveprop.Propagation + + @spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t() + def cells(conn, %{"band" => band_param} = params) do + with {band_mhz, ""} <- Integer.parse(band_param), + {:ok, valid_time} <- parse_optional_time(params["time"]), + {:ok, bounds} <- parse_bounds(params) do + scores = + if valid_time do + Propagation.scores_at(band_mhz, valid_time, bounds) + else + Propagation.latest_scores(band_mhz, bounds) + end + + conn + |> put_resp_header("content-type", "application/octet-stream") + |> put_resp_header("cache-control", "public, max-age=60") + |> send_resp(200, encode_binary(scores)) + else + _ -> bad_request(conn) + end + end + + def cells(conn, _params), do: bad_request(conn) + + defp bad_request(conn) do + conn + |> put_status(400) + |> json(%{error: "invalid params"}) + end + + defp parse_optional_time(nil), do: {:ok, nil} + + defp parse_optional_time(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, dt, _offset} -> {:ok, dt} + _ -> :error + end + end + + defp parse_optional_time(_), do: :error + + defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do + with {:ok, south} <- parse_float(s), + {:ok, north} <- parse_float(n), + {:ok, west} <- parse_float(w), + {:ok, east} <- parse_float(e) do + {:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}} + end + end + + defp parse_bounds(_), do: {:ok, nil} + + defp parse_float(value) when is_binary(value) do + case Float.parse(value) do + {f, ""} -> {:ok, f} + _ -> :error + end + end + + defp parse_float(_), do: :error + + # Binary cell-pack format. Each entry is a 0-100 score at a (lat, lon). + # Layout chosen so the f32 lat/lon arrays land on 4-byte boundaries + # for typed-array decoding in JS — DataView reads tolerate + # misalignment but Float32Array views over an ArrayBuffer don't. + # + # "PSCR" (4 bytes) — magic + # u8 — version (1) + # u8 × 3 — reserved padding (zero) + # u32 LE — cell count + # f32 LE × cell_count — latitudes + # f32 LE × cell_count — longitudes + # u8 × cell_count — scores (0..100) + defp encode_binary(scores) do + cell_count = length(scores) + + header = <<"PSCR", 1::8, 0::8, 0::8, 0::8, cell_count::little-32>> + + lats = scores |> Enum.map(&<<&1.lat * 1.0::little-float-32>>) |> IO.iodata_to_binary() + lons = scores |> Enum.map(&<<&1.lon * 1.0::little-float-32>>) |> IO.iodata_to_binary() + score_bytes = scores |> Enum.map(&<>) |> IO.iodata_to_binary() + + <> + end + + defp clamp_score(s) when is_integer(s) and s < 0, do: 0 + defp clamp_score(s) when is_integer(s) and s > 100, do: 100 + defp clamp_score(s) when is_integer(s), do: s + defp clamp_score(s) when is_float(s), do: s |> round() |> clamp_score() + defp clamp_score(_), do: 0 +end diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index da5704df..16cae79e 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -7,7 +7,6 @@ defmodule MicrowavepropWeb.MapLive do alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.PipelineStatus alias Microwaveprop.Terrain.Viewshed - alias Phoenix.LiveView.AsyncResult require Logger @@ -52,10 +51,9 @@ defmodule MicrowavepropWeb.MapLive do # LiveStash only persists the keys declared in `use LiveStash, # stored_keys: [...]` (selected_band + selected_time). On reconnect - # the recovered socket has - # just those — the rest of the mount-time assigns (bands, bounds, the - # initial score JSON payload, toggles, etc.) have to be rebuilt or the - # first render crashes with KeyError on :initial_scores_json. + # the recovered socket has just those — the rest of the mount-time + # assigns (bands, bounds, toggles, etc.) have to be rebuilt or the + # first render crashes with KeyError. {recovered_band, recovered_time, socket} = case LiveStash.recover_state(socket) do {:recovered, recovered} -> @@ -73,30 +71,9 @@ defmodule MicrowavepropWeb.MapLive do visitor = visitor_location(session) bounds = bounds_around(center) - # 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!(Propagation.pack_scores(scores)), AsyncResult.ok(scores), socket} - end - - # preload_forecast runs on the first map_bounds event instead of - # here — mount only knows a hardcoded fallback bounding box, so - # preloading now populates the forecast cache with scores for the - # wrong viewport and forecast-hour clicks show a small square of - # coverage instead of the full map. + # Score data is fetched by the client over HTTP (see /scores/cells + # and propagation_map_hook.ts). The LiveView only owns timeline, + # band selection, point detail, viewshed, and URL state. build_ts = Microwaveprop.Application.build_timestamp() @@ -105,8 +82,6 @@ defmodule MicrowavepropWeb.MapLive do page_title: "Propagation Prediction Map", bands: bands, selected_band: selected_band, - initial_scores_json: initial_scores_json, - initial_scores: initial_scores_assign, valid_times: valid_times, selected_time: selected_time, tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()), @@ -119,9 +94,7 @@ 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), - preload_forecast_timer: nil, - bounds_flush_timer: nil + deploy_ago: format_deploy_ago(build_ts) )} end @@ -224,17 +197,14 @@ defmodule MicrowavepropWeb.MapLive do band = if is_binary(band), do: String.to_integer(band), else: band valid_times = Propagation.available_valid_times(band) selected_time = closest_to_now(valid_times) - scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds) - - socket = schedule_preload_forecast(socket) socket = socket |> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time) |> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now())) |> MicrowavepropWeb.LiveStashGuard.stash() - |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) - |> push_event("update_band_info", %{band_info: band_info(band)}) + # band_mhz tells the client to refetch /scores/cells for the new band. + |> push_event("update_band_info", %{band_info: band_info(band), band_mhz: band}) |> push_timeline() |> patch_map_url() @@ -242,15 +212,15 @@ defmodule MicrowavepropWeb.MapLive do end def handle_event("select_time", %{"time" => time_str}, socket) do + # Same as set_selected_time — client owns the score fetch via HTTP. + # Kept as a separate event because the click target predates the + # split and cleaning every caller up isn't necessary. case DateTime.from_iso8601(time_str) do {:ok, time, _} -> - scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds) - socket = socket |> assign(:selected_time, time) |> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now())) - |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) |> patch_map_url() {:noreply, socket} @@ -325,18 +295,13 @@ defmodule MicrowavepropWeb.MapLive do end def handle_event("map_bounds", bounds, socket) do - # 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. + # The client owns the score fetch via /scores/cells now — this + # event only updates server-side bounds for URL patching, point + # detail bbox lookups, and reconnect state. socket = socket |> assign(:bounds, bounds) |> maybe_assign_center_zoom(bounds) - |> schedule_bounds_update() - |> schedule_preload_forecast() |> patch_map_url(replace: true) {:noreply, socket} @@ -368,87 +333,7 @@ defmodule MicrowavepropWeb.MapLive do {:noreply, socket} 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 - 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: Propagation.pack_scores(scores)})} - end - - def handle_info(:preload_forecast, socket) do - band = socket.assigns.selected_band - bounds = socket.assigns.bounds - selected = socket.assigns.selected_time - - # Forecast preload walks up to 18 valid_times; each call reads the - # band's .prop file off NFS and filters to the viewport. The reads - # are independent, so fan them out across 4 Tasks — serial ran at - # ~18 × single-read latency, which dominated time-to-interactive - # after a band change on /map. Preserves the original ordering - # since the JS hook expects `hours` in valid_time order. - forecast_times = Enum.reject(socket.assigns.valid_times, &(selected && DateTime.compare(&1, selected) == :eq)) - - # `on_timeout: :kill_task` + explicit filter on :ok keeps a slow NFS - # read from cascading the whole stream's :timeout exit up into the - # LiveView process. A timed-out forecast hour just drops from the - # preload payload; the client re-requests it on demand. - hours = - forecast_times - |> Task.async_stream( - fn t -> - %{ - time: DateTime.to_iso8601(t), - scores: Propagation.pack_scores(Propagation.scores_at(band, t, bounds)) - } - end, - max_concurrency: 4, - ordered: true, - timeout: 10_000, - on_timeout: :kill_task - ) - |> Enum.zip(forecast_times) - |> Enum.flat_map(fn - {{:ok, h}, _t} -> - [h] - - {{:exit, reason}, t} -> - Logger.error( - "MapLive forecast preload task crashed: band=#{band} valid_time=#{inspect(t)} reason=#{inspect(reason)}" - ) - - [] - end) - - {:noreply, - socket - |> assign(:preload_forecast_timer, nil) - |> push_event("preload_forecast", %{hours: hours})} - end - def handle_info({:propagation_updated, _valid_times}, socket) do band = socket.assigns.selected_band valid_times = Propagation.available_valid_times(band) @@ -460,24 +345,13 @@ defmodule MicrowavepropWeb.MapLive do closest_to_now(valid_times) end - # scores_at_fresh bypasses the cache and re-reads the .prop file so - # the map reflects the newly written forecast hour even when the - # ScoreCache still holds the previous chain's bytes. The propagation - # update arrives via `propagation:updated` while the cache refresh - # rides `propagation:cache`; the two topics deliver independently - # so reading through the cache here can race against stale data. - scores = - if selected_time, - do: Propagation.scores_at_fresh(band, selected_time, socket.assigns.bounds), - else: [] - - socket = schedule_preload_forecast(socket) - + # The client's score cache may be stale for the new forecast hours, + # but it'll refetch via /scores/cells when the user scrubs (and the + # update_timeline push below makes the new hours selectable). socket = socket |> assign(valid_times: valid_times, selected_time: selected_time) |> assign(:pipeline_status, PipelineStatus.current()) - |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) |> push_timeline() {:noreply, socket} @@ -491,12 +365,11 @@ defmodule MicrowavepropWeb.MapLive do if new_time && socket.assigns.selected_time && DateTime.after?(new_time, socket.assigns.selected_time) do - scores = Propagation.scores_at(socket.assigns.selected_band, new_time, socket.assigns.bounds) - + # Client picks up the cursor advance via update_timeline and + # refetches scores via HTTP for the new selected time. socket = socket |> assign(:selected_time, new_time) - |> push_event("update_scores", %{scores: Propagation.pack_scores(scores)}) |> push_timeline() |> patch_map_url(replace: true) @@ -532,23 +405,6 @@ defmodule MicrowavepropWeb.MapLive do end @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: Propagation.pack_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 points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end) Logger.info("Viewshed complete: #{length(points)} boundary points") @@ -625,45 +481,6 @@ 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 - - # 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 @@ -887,7 +704,6 @@ defmodule MicrowavepropWeb.MapLive do id="propagation-map" phx-hook="PropagationMap" phx-update="ignore" - data-scores={@initial_scores_json} data-band-info={Jason.encode!(band_info(@selected_band))} data-valid-times={ Jason.encode!( @@ -905,37 +721,6 @@ defmodule MicrowavepropWeb.MapLive do > - <%!-- 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> -
- <.icon name="hero-arrow-path" class="size-3.5 motion-safe:animate-spin" /> - Loading propagation scores… -
- - <:failed :let={_reason}> - - - - <%!-- Mobile-only floating controls --%>
+ ScoreCache.clear() + ScoresFile.prune_older_than(~U[2099-12-31 23:59:59Z]) + end) + + :ok + end + + describe "GET /scores/cells" do + test "returns 400 for missing or invalid params", %{conn: conn} do + conn = get(conn, ~p"/scores/cells?band=nope") + assert json_response(conn, 400) + end + + test "returns the binary score pack for the band's earliest valid_time when ?time is absent", + %{conn: conn} do + valid_time = ~U[2026-04-28 12:00:00Z] + ScoresFile.write!(10_000, valid_time, [%{lat: 33.0, lon: -97.0, score: 75}]) + + conn = + get( + conn, + ~p"/scores/cells?band=10000&south=32.5&north=33.5&west=-97.5&east=-96.5" + ) + + assert ["application/octet-stream"] = get_resp_header(conn, "content-type") + body = response(conn, 200) + + assert <<"PSCR", 1::8, _reserved::24, 1::little-32, rest::binary>> = body + + <> = rest + assert_in_delta lat, 33.0, 0.001 + assert_in_delta lon, -97.0, 0.001 + assert score == 75 + end + + test "with ?time returns scores for that valid_time", %{conn: conn} do + analysis = ~U[2026-04-28 12:00:00Z] + forecast = ~U[2026-04-28 13:00:00Z] + + ScoresFile.write!(10_000, analysis, [%{lat: 33.0, lon: -97.0, score: 50}]) + ScoresFile.write!(10_000, forecast, [%{lat: 33.0, lon: -97.0, score: 88}]) + + time = DateTime.to_iso8601(forecast) + + conn = + get( + conn, + ~p"/scores/cells?band=10000&time=#{time}&south=32.5&north=33.5&west=-97.5&east=-96.5" + ) + + body = response(conn, 200) + assert <<"PSCR", 1::8, _reserved::24, 1::little-32, rest::binary>> = body + <<_lat::little-float-32, _lon::little-float-32, score::8, _::binary>> = rest + assert score == 88 + end + + test "returns an empty pack when no data exists for the band", %{conn: conn} do + conn = + get( + conn, + ~p"/scores/cells?band=999999&south=32.0&north=34.0&west=-98.0&east=-96.0" + ) + + assert ["application/octet-stream"] = get_resp_header(conn, "content-type") + body = response(conn, 200) + assert <<"PSCR", 1::8, _reserved::24, 0::little-32>> = body + end + end +end diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index 909d2bb5..aaff6d6e 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -8,7 +8,6 @@ defmodule MicrowavepropWeb.MapLiveTest do alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.ScoreCache - alias Phoenix.LiveView.AsyncResult alias Phoenix.LiveView.Utils describe "mount" do @@ -32,9 +31,10 @@ defmodule MicrowavepropWeb.MapLiveTest do assert html =~ ~s(class="active") end - test "includes data-scores attribute", %{conn: conn} do + test "includes data-selected-band and data-selected-time attributes", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/map") - assert html =~ "data-scores" + assert html =~ "data-selected-band" + assert html =~ "data-selected-time" end test "renders grid toggle", %{conn: conn} do @@ -56,60 +56,25 @@ defmodule MicrowavepropWeb.MapLiveTest do end end - describe "async initial scores" do + describe "score data attributes" 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 - ) - + test "static HTTP render advertises selected band/time so the client can fetch /scores/cells", + %{conn: conn} do 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 + # data-* attributes seed the JS hook with the band + valid_time + # to fetch via /scores/cells. No score JSON is baked into the + # initial HTML anymore. + assert body =~ ~s(data-selected-band=) + assert body =~ ~s(data-selected-time=) + refute body =~ ~s(data-scores=) end end @@ -155,16 +120,13 @@ defmodule MicrowavepropWeb.MapLiveTest do assert url =~ "band=47000" end - test "select_band pushes update_scores to the client for the new band", %{conn: conn} do + test "select_band pushes update_band_info with the new band id so the client refetches", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/map") - # Drain any async initial push_event before asserting on the - # band-switch one. _ = render(lv) - render_click(lv, "select_band", %{"value" => 24_000}) - assert_push_event(lv, "update_scores", %{scores: _scores}) + assert_push_event(lv, "update_band_info", %{band_info: _info, band_mhz: 24_000}) end end @@ -334,14 +296,6 @@ defmodule MicrowavepropWeb.MapLiveTest do end end - describe "retry_initial_scores" do - test "event is accepted without crashing the LV", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/map") - render_click(lv, "retry_initial_scores") - assert Process.alive?(lv.pid) - end - end - describe "daily outlook strip" do test "no longer renders on the main map", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/map") @@ -616,13 +570,9 @@ defmodule MicrowavepropWeb.MapLiveTest do :ok end - test "pushes freshly-written scores when the cache still holds stale ones", %{conn: conn} do - # Use a valid_time close enough to now that available_valid_times - # keeps it in the timeline. + test "refreshes valid_times timeline so the client refetches on the new hour", %{conn: conn} do valid_time = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) - # Seed the on-disk store and warm the cache with the same content - # so the map has a baseline to render on mount. Propagation.replace_scores( [%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 40, factors: nil}], valid_time @@ -633,28 +583,14 @@ defmodule MicrowavepropWeb.MapLiveTest do {: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 .prop file on - # disk to the updated score, but do *not* touch the cache. This - # reproduces the race where the cache_refresh message has not yet - # been applied by the time MapLive handles propagation_updated. - Propagation.replace_scores( - [%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 90, factors: nil}], - valid_time - ) - - # Deliver the propagation_updated PubSub message directly. + # Score updates flow client-side via /scores/cells now; the + # LiveView only republishes the timeline so the client knows + # which valid_times are available. Verify the timeline push + # carries the seeded valid_time. send(lv.pid, {:propagation_updated, [valid_time]}) - # The map must emit the new score (90), not the stale cached one (40). - # Wire format is packed: each point is `[lat, lon, score]`. - assert_push_event(lv, "update_scores", %{scores: scores}) - assert Enum.any?(scores, fn [_, _, score] -> score == 90 end) - refute Enum.any?(scores, fn [_, _, score] -> score == 40 end) + assert_push_event(lv, "update_timeline", %{times: times}) + assert Enum.any?(times, fn t -> t.time == DateTime.to_iso8601(valid_time) end) end end