chore(weather): drop tile endpoint and JSON cells back-compat

The client moved to the binary cell-pack and canvas overlay last
commit, so the per-tile SVG endpoint, the TileRenderer module, and the
JSON shape of /weather/cells are all dead code. Remove them.

/weather/cells now always returns the binary WCEL pack (the ?format=bin
toggle is gone — there's only one format). The data-weather-tile-url
attribute and the weather_tile_url assign are dropped from the LiveView.

Also fix a latent bug surfaced once tests covered the default-layers
path: nil row values were writing the atom :nan into the binary segment
at runtime. Replaced with the explicit f32 quiet-NaN bit pattern so
Number.isNaN flags them correctly on the JS side.
This commit is contained in:
Graham McIntire 2026-04-29 13:45:43 -05:00
parent af8b787915
commit 2de1b89efc
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 53 additions and 473 deletions

View file

@ -1,290 +0,0 @@
defmodule Microwaveprop.Weather.TileRenderer do
@moduledoc false
alias Microwaveprop.Weather
@tile_size 256
@grid_step 0.125
@half_step @grid_step / 2.0
@overlay_alpha 0.55
@continuous_scales %{
"refractivity_gradient" => [
{-500, {0, 255, 163}},
{-300, {125, 255, 212}},
{-200, {255, 229, 102}},
{-100, {255, 144, 68}},
{0, {255, 79, 79}}
],
"bl_height" => [
{0, {0, 255, 163}},
{500, {125, 255, 212}},
{1000, {255, 229, 102}},
{2000, {255, 144, 68}},
{3000, {255, 79, 79}}
],
"dewpoint_depression" => [
{0, {0, 255, 163}},
{5, {125, 255, 212}},
{10, {255, 229, 102}},
{20, {255, 144, 68}},
{30, {255, 79, 79}}
],
"pwat" => [
{0, {139, 90, 43}},
{15, {194, 165, 116}},
{30, {173, 216, 230}},
{45, {65, 105, 225}},
{60, {0, 0, 180}}
],
"temperature" => [
{-20, {0, 0, 200}},
{0, {100, 149, 237}},
{15, {255, 255, 150}},
{30, {255, 140, 0}},
{45, {200, 0, 0}}
],
"surface_rh" => [
{0, {255, 144, 68}},
{30, {255, 229, 102}},
{60, {125, 255, 212}},
{80, {0, 255, 163}},
{100, {0, 200, 130}}
],
"surface_refractivity" => [
{250, {255, 79, 79}},
{300, {255, 144, 68}},
{330, {255, 229, 102}},
{360, {125, 255, 212}},
{400, {0, 255, 163}}
],
"temp_850mb" => [
{-20, {0, 0, 200}},
{-5, {100, 149, 237}},
{5, {255, 255, 150}},
{15, {255, 140, 0}},
{30, {200, 0, 0}}
],
"dewpoint_850mb" => [
{-20, {139, 90, 43}},
{-5, {194, 165, 116}},
{5, {173, 216, 230}},
{15, {65, 105, 225}},
{25, {0, 0, 180}}
],
"temp_700mb" => [
{-10, {0, 100, 200}},
{0, {100, 180, 230}},
{5, {200, 230, 200}},
{8, {255, 229, 102}},
{10, {255, 144, 68}},
{14, {200, 0, 0}}
],
"dewpoint_700mb" => [
{-30, {139, 90, 43}},
{-15, {194, 165, 116}},
{-5, {173, 216, 230}},
{5, {65, 105, 225}},
{15, {0, 0, 180}}
],
"lapse_rate" => [
{3, {0, 255, 163}},
{5, {125, 255, 212}},
{6.5, {255, 229, 102}},
{8, {255, 144, 68}},
{9.5, {255, 79, 79}}
],
"mid_lapse_rate" => [
{3, {0, 255, 163}},
{5, {125, 255, 212}},
{6.5, {255, 229, 102}},
{7.5, {255, 144, 68}},
{9, {255, 79, 79}}
],
"inversion_strength" => [
{0, {255, 229, 102}},
{1, {255, 200, 80}},
{3, {255, 144, 68}},
{5, {255, 79, 79}},
{8, {180, 0, 0}}
],
"inversion_base_m" => [
{0, {0, 255, 163}},
{100, {125, 255, 212}},
{300, {255, 229, 102}},
{800, {255, 144, 68}},
{1500, {255, 79, 79}}
],
"duct_base_m" => [
{0, {0, 255, 163}},
{100, {125, 255, 212}},
{300, {255, 229, 102}},
{800, {255, 144, 68}},
{1500, {255, 79, 79}}
],
"duct_strength" => [
{0, {125, 255, 212}},
{5, {255, 229, 102}},
{10, {255, 144, 68}},
{20, {255, 79, 79}},
{30, {180, 0, 0}}
],
"surface_pressure_mb" => [
{980, {255, 79, 79}},
{1000, {255, 144, 68}},
{1015, {255, 229, 102}},
{1030, {125, 255, 212}},
{1045, {0, 255, 163}}
]
}
@boolean_scales %{
"ducting" => {0, 255, 163}
}
@layer_fields Map.new(Map.keys(@continuous_scales) ++ Map.keys(@boolean_scales), &{&1, String.to_atom(&1)})
@spec render_svg(DateTime.t(), String.t(), non_neg_integer(), non_neg_integer(), non_neg_integer()) ::
binary()
def render_svg(%DateTime{} = valid_time, layer_id, z, x, y) do
bounds =
z
|> tile_bounds(x, y)
|> pad_bounds(@half_step)
rows = Weather.weather_grid_at(valid_time, bounds)
IO.iodata_to_binary([
~s(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{@tile_size} #{@tile_size}" shape-rendering="crispEdges">),
render_rows(rows, layer_id, z, x, y),
"</svg>"
])
end
@spec empty_svg() :: binary()
def empty_svg do
~s(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{@tile_size} #{@tile_size}"></svg>)
end
defp render_rows(rows, layer_id, z, x, y) do
Enum.map(rows, fn row -> render_row(row, layer_id, z, x, y) end)
end
defp render_row(row, layer_id, z, x, y) do
with {:ok, {fill, alpha}} <- color_for_layer(row, layer_id),
{:ok, {px, py, width, height}} <- rect_for_row(row, z, x, y) do
~s(<rect x="#{fmt(px)}" y="#{fmt(py)}" width="#{fmt(width)}" height="#{fmt(height)}" fill="#{fill}" fill-opacity="#{alpha}" />)
else
_ -> []
end
end
defp color_for_layer(row, layer_id) do
field = Map.get(@layer_fields, layer_id)
case Map.fetch(@boolean_scales, layer_id) do
{:ok, {r, g, b}} ->
if row[field] do
{:ok, {"rgb(#{r},#{g},#{b})", fmt(@overlay_alpha)}}
else
:skip
end
:error ->
with value when is_number(value) <- row[field],
scale when is_list(scale) <- Map.get(@continuous_scales, layer_id),
{r, g, b} <- interpolate(value, scale) do
{:ok, {"rgb(#{r},#{g},#{b})", fmt(@overlay_alpha)}}
else
_ -> :skip
end
end
end
defp interpolate(value, [{min_value, min_color} | _]) when value <= min_value, do: min_color
defp interpolate(value, scale) do
max_value = scale |> List.last() |> elem(0)
if value >= max_value do
scale |> List.last() |> elem(1)
else
scale
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find_value(&interpolate_pair(&1, value))
end
end
defp interpolate_pair([{v1, c1}, {v2, c2}], value) when value >= v1 and value <= v2 do
t = (value - v1) / (v2 - v1)
{lerp(elem(c1, 0), elem(c2, 0), t), lerp(elem(c1, 1), elem(c2, 1), t), lerp(elem(c1, 2), elem(c2, 2), t)}
end
defp interpolate_pair(_pair, _value), do: nil
defp lerp(a, b, t), do: round(a + (b - a) * t)
defp rect_for_row(%{lat: lat, lon: lon}, z, tile_x, tile_y) do
x1 = world_x(lon - @half_step, z) - tile_x * @tile_size
x2 = world_x(lon + @half_step, z) - tile_x * @tile_size
y1 = world_y(lat + @half_step, z) - tile_y * @tile_size
y2 = world_y(lat - @half_step, z) - tile_y * @tile_size
left = max(min(x1, x2), 0.0)
right = min(max(x1, x2), @tile_size * 1.0)
top = max(min(y1, y2), 0.0)
bottom = min(max(y1, y2), @tile_size * 1.0)
width = right - left
height = bottom - top
if width <= 0.0 or height <= 0.0 do
:skip
else
{:ok, {left, top, width, height}}
end
end
defp tile_bounds(z, x, y) do
%{
"west" => tile_to_lon(x, z),
"east" => tile_to_lon(x + 1, z),
"north" => tile_to_lat(y, z),
"south" => tile_to_lat(y + 1, z)
}
end
defp pad_bounds(%{"west" => west, "east" => east, "north" => north, "south" => south}, pad) do
%{
"west" => west - pad,
"east" => east + pad,
"north" => min(north + pad, 85.0511),
"south" => max(south - pad, -85.0511)
}
end
defp tile_to_lon(x, z), do: x / :math.pow(2, z) * 360.0 - 180.0
defp tile_to_lat(y, z) do
n = :math.pi() - 2.0 * :math.pi() * y / :math.pow(2, z)
:math.atan(:math.sinh(n)) * 180.0 / :math.pi()
end
defp world_x(lon, z) do
(lon + 180.0) / 360.0 * @tile_size * :math.pow(2, z)
end
defp world_y(lat, z) do
lat =
lat
|> min(85.0511)
|> max(-85.0511)
lat_rad = lat * :math.pi() / 180.0
(1.0 - :math.log(:math.tan(lat_rad) + 1.0 / :math.cos(lat_rad)) / :math.pi()) / 2.0 *
@tile_size * :math.pow(2, z)
end
defp fmt(value) when is_integer(value), do: Integer.to_string(value)
defp fmt(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 2)
end

View file

@ -2,12 +2,9 @@ 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.
# Layer fields the binary cell-pack can carry. Used both as the
# whitelist for ?layers=... and as the default if the param is absent.
@cell_fields ~w(
temperature dewpoint_depression surface_rh surface_pressure_mb
surface_refractivity refractivity_gradient bl_height pwat
@ -16,28 +13,6 @@ defmodule MicrowavepropWeb.WeatherTileController do
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),
@ -45,21 +20,10 @@ defmodule MicrowavepropWeb.WeatherTileController do
{: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
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))
else
_ -> bad_request(conn)
end
@ -93,12 +57,6 @@ defmodule MicrowavepropWeb.WeatherTileController do
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
@ -152,15 +110,13 @@ defmodule MicrowavepropWeb.WeatherTileController do
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()
lats = rows |> Enum.map(&encode_f32(&1.lat)) |> IO.iodata_to_binary()
lons = rows |> Enum.map(&encode_f32(&1.lon)) |> 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)
Enum.map(rows, fn row -> encode_value_f32(Map.get(row, field)) end)
end)
|> IO.iodata_to_binary()
@ -172,12 +128,16 @@ defmodule MicrowavepropWeb.WeatherTileController do
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
# f32 quiet NaN bit pattern in little-endian — used for null values so
# the JS side can check Number.isNaN on a Float32Array entry.
@f32_nan <<0::8, 0::8, 0xC0::8, 0x7F::8>>
defp float_or_nan(v) when is_number(v), do: v * 1.0
defp float_or_nan(_), do: :nan
defp encode_value_f32(nil), do: @f32_nan
defp encode_value_f32(true), do: <<1.0::little-float-32>>
defp encode_value_f32(false), do: <<0.0::little-float-32>>
defp encode_value_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
defp encode_value_f32(_), do: @f32_nan
defp encode_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
defp encode_f32(_), do: @f32_nan
end

View file

@ -40,7 +40,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
initial_selected_time: initial_vt && DateTime.to_iso8601(initial_vt),
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
weather_tile_url: "/weather/tiles",
grid_visible: false,
radar_visible: false
)}
@ -214,7 +213,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do
data-selected-layer={@selected_layer}
data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time}
data-weather-tile-url={@weather_tile_url}
class="absolute inset-0 z-0"
>
</div>

View file

@ -180,7 +180,6 @@ defmodule MicrowavepropWeb.Router do
get "/", PageController, :home
get "/api/contacts/map", ContactMapController, :show
get "/weather/tiles/:z/:x/:y", WeatherTileController, :show
get "/weather/cells", WeatherTileController, :cells
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do

View file

@ -15,43 +15,13 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
: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
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 the binary cell pack with all default layers when ?layers is absent", %{conn: conn} do
valid_time = ~U[2026-04-28 12:00:00Z]
ProfilesFile.write!(valid_time, %{
@ -62,14 +32,6 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
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: []
}
})
@ -81,72 +43,12 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
~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")
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
body = response(conn, 200)
assert <<"WCEL", 1::8, _cell_count::little-32, _layer_count::8, _rest::binary>> = body
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
test "with ?layers=foo,bar limits the binary pack to those layers", %{conn: conn} do
valid_time = ~U[2026-04-28 12:00:00Z]
ProfilesFile.write!(valid_time, %{
@ -173,30 +75,40 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
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"
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0&layers=temperature,pwat"
)
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)
# 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.
# Header so far: 4(magic) + 1 + 4 + 1 + (1+11) + (1+4) = 27 bytes. One
# byte of zero padding aligns the f32 arrays to a 4-byte boundary.
<<0::8, lats_and_more::binary>> = padded_rest
<<lat1::little-float-32, lat2::little-float-32, rest_after_lats::binary>> =
lats_and_more
<<lat1::little-float-32, lat2::little-float-32, _rest::binary>> = lats_and_more
assert_in_delta lat1, 33.0, 0.001
assert_in_delta lat2, 33.125, 0.001
end
<<_lon1::little-float-32, _lon2::little-float-32, _rest::binary>> = rest_after_lats
test "returns an empty pack 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"
)
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
body = response(conn, 200)
assert <<"WCEL", 1::8, 0::little-32, _layer_count::8, _rest::binary>> = body
end
end
end

View file

@ -216,11 +216,12 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
end
end
describe "tile overlay bootstrapping" do
test "renders the weather tile base URL on the map element", %{conn: conn} do
describe "overlay bootstrapping" do
test "seeds the weather map element with the initial valid_time", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather")
assert html =~ ~s(data-weather-tile-url="/weather/tiles")
assert html =~ ~s(id="weather-map")
assert html =~ ~s(data-selected-layer=)
end
end
end