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.
202 lines
5.7 KiB
Elixir
202 lines
5.7 KiB
Elixir
defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
|
use MicrowavepropWeb.ConnCase, async: false
|
|
|
|
alias Microwaveprop.Propagation.ProfilesFile
|
|
alias Microwaveprop.Weather.GridCache
|
|
|
|
setup do
|
|
GridCache.clear()
|
|
|
|
on_exit(fn ->
|
|
GridCache.clear()
|
|
ProfilesFile.prune_older_than(~U[2099-12-31 23:59:59Z])
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "serves an SVG weather tile for the requested viewport", %{conn: conn} do
|
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
|
lat = 33.0
|
|
lon = -97.0
|
|
|
|
ProfilesFile.write!(valid_time, %{
|
|
{lat, lon} => %{
|
|
surface_temp_c: 22.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1010.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 25.0,
|
|
profile: []
|
|
}
|
|
})
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/tiles/0/0/0?layer=temperature&time=#{DateTime.to_iso8601(valid_time)}"
|
|
)
|
|
|
|
assert response(conn, 200) =~ "<svg"
|
|
assert response(conn, 200) =~ "<rect"
|
|
assert ["image/svg+xml; charset=utf-8"] = get_resp_header(conn, "content-type")
|
|
end
|
|
|
|
test "returns an empty svg for invalid params", %{conn: conn} do
|
|
conn = get(conn, ~p"/weather/tiles/6/18/25?layer=bogus&time=not-a-time")
|
|
|
|
body = response(conn, 200)
|
|
assert body =~ "<svg"
|
|
refute body =~ "<rect"
|
|
end
|
|
|
|
describe "GET /weather/cells" do
|
|
test "returns viewport cells as JSON", %{conn: conn} do
|
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
|
|
|
ProfilesFile.write!(valid_time, %{
|
|
{33.0, -97.0} => %{
|
|
surface_temp_c: 22.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1010.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 25.0,
|
|
profile: []
|
|
},
|
|
{33.125, -97.0} => %{
|
|
surface_temp_c: 23.0,
|
|
surface_dewpoint_c: 13.0,
|
|
surface_pressure_mb: 1011.0,
|
|
hpbl_m: 700.0,
|
|
pwat_mm: 26.0,
|
|
profile: []
|
|
}
|
|
})
|
|
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=32.5&north=33.5&west=-97.5&east=-96.5"
|
|
)
|
|
|
|
assert ["application/json; charset=utf-8"] = get_resp_header(conn, "content-type")
|
|
body = json_response(conn, 200)
|
|
|
|
assert is_list(body["cells"])
|
|
assert length(body["cells"]) == 2
|
|
assert body["valid_time"] == time
|
|
|
|
first = hd(body["cells"])
|
|
assert is_number(first["lat"])
|
|
assert is_number(first["lon"])
|
|
assert Map.has_key?(first, "temperature")
|
|
assert Map.has_key?(first, "dewpoint_depression")
|
|
end
|
|
|
|
test "returns 400 for missing or invalid params", %{conn: conn} do
|
|
conn = get(conn, ~p"/weather/cells?time=not-a-time")
|
|
assert json_response(conn, 400)
|
|
end
|
|
|
|
test "returns empty cells when no data exists for the valid_time", %{conn: conn} do
|
|
valid_time = ~U[2099-04-28 12:00:00Z]
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0"
|
|
)
|
|
|
|
body = json_response(conn, 200)
|
|
assert body["cells"] == []
|
|
end
|
|
|
|
test "with ?layers=foo,bar only includes those fields per cell", %{conn: conn} do
|
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
|
|
|
ProfilesFile.write!(valid_time, %{
|
|
{33.0, -97.0} => %{
|
|
surface_temp_c: 22.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1010.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 25.0,
|
|
profile: []
|
|
}
|
|
})
|
|
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0&layers=temperature,pwat"
|
|
)
|
|
|
|
body = json_response(conn, 200)
|
|
[cell | _] = body["cells"]
|
|
assert Map.has_key?(cell, "lat")
|
|
assert Map.has_key?(cell, "lon")
|
|
assert Map.has_key?(cell, "temperature")
|
|
assert Map.has_key?(cell, "pwat")
|
|
refute Map.has_key?(cell, "dewpoint_depression")
|
|
refute Map.has_key?(cell, "surface_rh")
|
|
end
|
|
|
|
test "with ?format=bin returns the binary cell pack", %{conn: conn} do
|
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
|
|
|
ProfilesFile.write!(valid_time, %{
|
|
{33.0, -97.0} => %{
|
|
surface_temp_c: 22.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1010.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 25.0,
|
|
profile: []
|
|
},
|
|
{33.125, -97.0} => %{
|
|
surface_temp_c: 23.0,
|
|
surface_dewpoint_c: 13.0,
|
|
surface_pressure_mb: 1011.0,
|
|
hpbl_m: 700.0,
|
|
pwat_mm: 26.0,
|
|
profile: []
|
|
}
|
|
})
|
|
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0&layers=temperature,pwat&format=bin"
|
|
)
|
|
|
|
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
|
|
body = response(conn, 200)
|
|
|
|
# Magic + version + cell count + layer count + 2 layer names + lats + lons + 2 fields
|
|
assert <<"WCEL", 1::8, cell_count::little-32, 2::8, rest::binary>> = body
|
|
assert cell_count == 2
|
|
|
|
# Two layer names: "temperature" (11), "pwat" (4)
|
|
<<11::8, "temperature", 4::8, "pwat", padded_rest::binary>> = rest
|
|
|
|
# The header so far is: 4(magic) + 1 + 4 + 1 + (1+11) + (1+4) = 27 bytes.
|
|
# Pad to 4-byte boundary: 1 byte of zero padding → 28 bytes.
|
|
<<0::8, lats_and_more::binary>> = padded_rest
|
|
|
|
<<lat1::little-float-32, lat2::little-float-32, rest_after_lats::binary>> =
|
|
lats_and_more
|
|
|
|
assert_in_delta lat1, 33.0, 0.001
|
|
assert_in_delta lat2, 33.125, 0.001
|
|
|
|
<<_lon1::little-float-32, _lon2::little-float-32, _rest::binary>> = rest_after_lats
|
|
end
|
|
end
|
|
end
|