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.
102 lines
3.4 KiB
Elixir
102 lines
3.4 KiB
Elixir
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
|