Three changes that shrink first-paint and make layer toggles instant: 1. Server: /weather/cells now supports ?format=bin and ?layers=a,b,c. The binary "WCEL" pack is ~3× smaller than JSON (Float32 columns, no key overhead) and parses with DataView + typed-array views — essentially memcpy on the client. 2. Client: replaced JSON parse + per-cell SVG <rect> with a custom Leaflet canvas layer. The pack is columnar (lats/lons + Float32Array per layer) and each draw is one fillRect per cell against a precomputed 256-step color LUT. At low zoom (CONUS, ~80k cells) SVG paint cost dominated; canvas keeps it under one frame. 3. Lazy prefetch: the initial fetch only requests the *current* layer (~1/19 the bytes). Right after paint, a single background fetch pulls every other layer into the same pack. Layer switches that land after the prefetch finishes are pure recolor — no network, no server CPU. The /weather/tiles endpoint stays for backward compat. The JSON path of /weather/cells stays too — the binary path is opt-in via ?format=bin.
183 lines
5.9 KiB
Elixir
183 lines
5.9 KiB
Elixir
defmodule MicrowavepropWeb.WeatherTileController do
|
||
use MicrowavepropWeb, :controller
|
||
|
||
alias Microwaveprop.Weather
|
||
alias Microwaveprop.Weather.MapLayers
|
||
alias Microwaveprop.Weather.TileRenderer
|
||
|
||
# Fields shipped to the client. The client recolors locally on layer
|
||
# switch from this same payload, so include every field any layer
|
||
# might need.
|
||
@cell_fields ~w(
|
||
temperature dewpoint_depression surface_rh surface_pressure_mb
|
||
surface_refractivity refractivity_gradient bl_height pwat
|
||
temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb
|
||
lapse_rate mid_lapse_rate inversion_strength inversion_base_m
|
||
ducting duct_base_m duct_strength
|
||
)a
|
||
|
||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||
def show(conn, %{"z" => z, "x" => x, "y" => y, "layer" => layer_id, "time" => time_param}) do
|
||
with true <- MapLayers.valid_id?(layer_id),
|
||
{z, ""} <- Integer.parse(z),
|
||
{x, ""} <- Integer.parse(x),
|
||
{y, ""} <- Integer.parse(y),
|
||
{:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param) do
|
||
body = TileRenderer.render_svg(valid_time, layer_id, z, x, y)
|
||
|
||
conn
|
||
|> put_resp_content_type("image/svg+xml")
|
||
|> put_resp_header("cache-control", "public, max-age=60")
|
||
|> send_resp(200, body)
|
||
else
|
||
_ ->
|
||
conn
|
||
|> put_resp_content_type("image/svg+xml")
|
||
|> put_resp_header("cache-control", "public, max-age=5")
|
||
|> send_resp(200, TileRenderer.empty_svg())
|
||
end
|
||
end
|
||
|
||
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||
def cells(conn, %{"time" => time_param} = params) do
|
||
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
|
||
{:ok, bounds} <- parse_bounds(params),
|
||
{:ok, layers} <- parse_layers(params["layers"]) do
|
||
rows = Weather.weather_grid_at(valid_time, bounds)
|
||
|
||
case params["format"] do
|
||
"bin" ->
|
||
conn
|
||
|> put_resp_header("content-type", "application/octet-stream")
|
||
|> put_resp_header("cache-control", "public, max-age=60")
|
||
|> send_resp(200, encode_binary(rows, layers))
|
||
|
||
_ ->
|
||
conn
|
||
|> put_resp_header("cache-control", "public, max-age=60")
|
||
|> json(%{
|
||
valid_time: DateTime.to_iso8601(valid_time),
|
||
cells: Enum.map(rows, &cell_payload(&1, layers))
|
||
})
|
||
end
|
||
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_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: :error
|
||
|
||
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
|
||
|
||
defp cell_payload(row, layers) do
|
||
Enum.reduce(layers, %{lat: row.lat, lon: row.lon}, fn field, acc ->
|
||
Map.put(acc, field, Map.get(row, field))
|
||
end)
|
||
end
|
||
|
||
defp parse_layers(nil), do: {:ok, @cell_fields}
|
||
|
||
defp parse_layers(value) when is_binary(value) do
|
||
fields =
|
||
value
|
||
|> String.split(",", trim: true)
|
||
|> Enum.map(&String.trim/1)
|
||
|> Enum.map(&safe_atom/1)
|
||
|
||
cond do
|
||
Enum.any?(fields, &is_nil/1) -> :error
|
||
Enum.empty?(fields) -> {:ok, @cell_fields}
|
||
true -> {:ok, fields}
|
||
end
|
||
end
|
||
|
||
defp parse_layers(_), do: :error
|
||
|
||
defp safe_atom(name) when is_binary(name) do
|
||
if name in Enum.map(@cell_fields, &Atom.to_string/1) do
|
||
String.to_existing_atom(name)
|
||
end
|
||
end
|
||
|
||
# Custom binary cell-pack format. Designed for cheap parsing in JS via
|
||
# DataView + Float32Array (no library, no JSON parse). The header is
|
||
# padded so all f32 arrays land on 4-byte boundaries — DataView reads
|
||
# tolerate misalignment but typed-array views over an ArrayBuffer
|
||
# don't.
|
||
#
|
||
# "WCEL" (4 bytes) — magic
|
||
# u8 — version (1)
|
||
# u32 LE — cell count
|
||
# u8 — layer count
|
||
# for each layer:
|
||
# u8 — name length
|
||
# u8 × name_length — name (UTF-8)
|
||
# u8 × N — zero padding to next 4-byte boundary
|
||
# f32 LE × cell_count — latitudes
|
||
# f32 LE × cell_count — longitudes
|
||
# for each layer:
|
||
# f32 LE × cell_count — values (NaN = null; booleans 0.0/1.0)
|
||
defp encode_binary(rows, layers) do
|
||
cell_count = length(rows)
|
||
layer_names = Enum.map(layers, &Atom.to_string/1)
|
||
|
||
header =
|
||
<<"WCEL", 1::8, cell_count::little-32, length(layer_names)::8>> <>
|
||
Enum.map_join(layer_names, fn n -> <<byte_size(n)::8>> <> n end)
|
||
|
||
pad_size = padding_to_align(byte_size(header), 4)
|
||
header_padded = header <> :binary.copy(<<0>>, pad_size)
|
||
|
||
lats = rows |> Enum.map(&<<float_or_nan(&1.lat)::little-float-32>>) |> IO.iodata_to_binary()
|
||
lons = rows |> Enum.map(&<<float_or_nan(&1.lon)::little-float-32>>) |> IO.iodata_to_binary()
|
||
|
||
values =
|
||
layers
|
||
|> Enum.map(fn field ->
|
||
Enum.map(rows, fn row ->
|
||
<<encode_value(Map.get(row, field))::little-float-32>>
|
||
end)
|
||
end)
|
||
|> IO.iodata_to_binary()
|
||
|
||
<<header_padded::binary, lats::binary, lons::binary, values::binary>>
|
||
end
|
||
|
||
defp padding_to_align(size, align) do
|
||
rem = rem(size, align)
|
||
if rem == 0, do: 0, else: align - rem
|
||
end
|
||
|
||
defp encode_value(nil), do: :nan
|
||
defp encode_value(true), do: 1.0
|
||
defp encode_value(false), do: 0.0
|
||
defp encode_value(v) when is_number(v), do: v * 1.0
|
||
defp encode_value(_), do: :nan
|
||
|
||
defp float_or_nan(v) when is_number(v), do: v * 1.0
|
||
defp float_or_nan(_), do: :nan
|
||
end
|