defmodule MicrowavepropWeb.ScoresController do @moduledoc """ HTTP endpoint serving propagation score grids in a compact binary cell-pack format. Replaces the old `update_scores` LiveView push for the /map overlay so the client can fetch directly (cacheable, doesn't block the LiveView channel) and decode via DataView + typed-array views without a JSON parse. """ use MicrowavepropWeb, :controller alias Microwaveprop.Propagation @spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t() def cells(conn, %{"band" => band_param} = params) do with {band_mhz, ""} <- Integer.parse(band_param), {:ok, valid_time} <- parse_optional_time(params["time"]), {:ok, bounds} <- parse_bounds(params) do scores = if valid_time do Propagation.scores_at(band_mhz, valid_time, bounds) else Propagation.latest_scores(band_mhz, bounds) end conn |> put_resp_header("content-type", "application/octet-stream") |> put_resp_header("cache-control", "public, max-age=60") |> send_resp(200, encode_binary(scores)) else _ -> bad_request(conn) end end def cells(conn, _params), do: bad_request(conn) defp bad_request(conn) do conn |> put_status(400) |> json(%{error: "invalid params"}) end defp parse_optional_time(nil), do: {:ok, nil} defp parse_optional_time(value) when is_binary(value) do case DateTime.from_iso8601(value) do {:ok, dt, _offset} -> {:ok, dt} _ -> :error end end defp parse_optional_time(_), do: :error defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do with {:ok, south} <- parse_float(s), {:ok, north} <- parse_float(n), {:ok, west} <- parse_float(w), {:ok, east} <- parse_float(e) do {:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}} end end defp parse_bounds(_), do: {:ok, nil} defp parse_float(value) when is_binary(value) do case Float.parse(value) do {f, ""} -> {:ok, f} _ -> :error end end defp parse_float(_), do: :error # Binary cell-pack format. Each entry is a 0-100 score at a (lat, lon). # Layout chosen so the f32 lat/lon arrays land on 4-byte boundaries # for typed-array decoding in JS — DataView reads tolerate # misalignment but Float32Array views over an ArrayBuffer don't. # # "PSCR" (4 bytes) — magic # u8 — version (1) # u8 × 3 — reserved padding (zero) # u32 LE — cell count # f32 LE × cell_count — latitudes # f32 LE × cell_count — longitudes # u8 × cell_count — scores (0..100) defp encode_binary(scores) do cell_count = length(scores) header = <<"PSCR", 1::8, 0::8, 0::8, 0::8, cell_count::little-32>> lats = scores |> Enum.map(&<<&1.lat * 1.0::little-float-32>>) |> IO.iodata_to_binary() lons = scores |> Enum.map(&<<&1.lon * 1.0::little-float-32>>) |> IO.iodata_to_binary() score_bytes = scores |> Enum.map(&<>) |> IO.iodata_to_binary() <> end defp clamp_score(s) when is_integer(s) and s < 0, do: 0 defp clamp_score(s) when is_integer(s) and s > 100, do: 100 defp clamp_score(s) when is_integer(s), do: s defp clamp_score(s) when is_float(s), do: s |> round() |> clamp_score() defp clamp_score(_), do: 0 end