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:
parent
af8b787915
commit
2de1b89efc
6 changed files with 53 additions and 473 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue