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:
parent
ed8b080b13
commit
6a90d153ca
7 changed files with 391 additions and 393 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()}]
|
||||
|
|
|
|||
102
lib/microwaveprop_web/controllers/scores_controller.ex
Normal file
102
lib/microwaveprop_web/controllers/scores_controller.ex
Normal 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
|
||||
|
|
@ -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
|
|||
>
|
||||
</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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue