perf(map): /scores/cells binary endpoint, client-side fetch + band/time prefetch

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.
This commit is contained in:
Graham McIntire 2026-04-29 14:27:20 -05:00
parent ed8b080b13
commit 6a90d153ca
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 391 additions and 393 deletions

View file

@ -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<string, ScorePoint[]>
// Cache key: `${band}|${time}|${roundedBoundsKey}` → ScorePoint[]
scoresCache: Map<string, ScorePoint[]>
scoresBoundsKey: string
inflightScoresAbort: AbortController | null
playbackTimer: ReturnType<typeof setInterval> | null
moveDebounce: ReturnType<typeof setTimeout> | 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<ScorePoint[]>
loadAndRender(this: PropagationMapHook, opts?: { force?: boolean }): Promise<void>
prefetchForecast(this: PropagationMapHook): Promise<void>
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<string, unknown> & {
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<string, unknown> & {
{ 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<string, unknown> & {
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<string, unknown> & {
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<string, unknown> & {
},
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<string, unknown> & {
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<ScorePoint[]> {
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<void> {
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<void> {
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) {

View file

@ -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 ~4045% 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()}]

View file

@ -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(&<<clamp_score(&1.score)::8>>) |> IO.iodata_to_binary()
<<header::binary, lats::binary, lons::binary, score_bytes::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

View file

@ -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
# 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.
# 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 (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
@ -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
>
</div>
<%!-- 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>
<div
id="initial-scores-loading"
role="status"
aria-live="polite"
class="absolute top-2 right-2 z-[1002] bg-neutral text-neutral-content shadow-lg rounded-box border border-base-300 px-3 py-2 text-xs flex items-center gap-2"
>
<.icon name="hero-arrow-path" class="size-3.5 motion-safe:animate-spin" />
<span>Loading propagation scores</span>
</div>
</:loading>
<:failed :let={_reason}>
<div
id="initial-scores-failed"
role="alert"
class="absolute top-2 right-2 z-[1002] bg-error text-error-content shadow-lg rounded-box px-3 py-2 text-xs flex items-center gap-2"
>
<.icon name="hero-exclamation-triangle" class="size-3.5" />
<span>Couldn't load scores.</span>
<button type="button" class="btn btn-xs btn-ghost" phx-click="retry_initial_scores">
Retry
</button>
</div>
</:failed>
</.async_result>
<%!-- Mobile-only floating controls --%>
<div
id="map-controls"

View file

@ -181,6 +181,7 @@ defmodule MicrowavepropWeb.Router do
get "/", PageController, :home
get "/api/contacts/map", ContactMapController, :show
get "/weather/cells", WeatherTileController, :cells
get "/scores/cells", ScoresController, :cells
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
live "/submit", SubmitLive

View file

@ -0,0 +1,79 @@
defmodule MicrowavepropWeb.ScoresControllerTest do
use MicrowavepropWeb.ConnCase, async: false
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Propagation.ScoresFile
setup do
ScoreCache.clear()
on_exit(fn ->
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
<<lat::little-float-32, lon::little-float-32, score::8, _rest::binary>> = 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

View file

@ -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