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
|
use MicrowavepropWeb, :controller
|
||||||
|
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.MapLayers
|
|
||||||
alias Microwaveprop.Weather.TileRenderer
|
|
||||||
|
|
||||||
# Fields shipped to the client. The client recolors locally on layer
|
# Layer fields the binary cell-pack can carry. Used both as the
|
||||||
# switch from this same payload, so include every field any layer
|
# whitelist for ?layers=... and as the default if the param is absent.
|
||||||
# might need.
|
|
||||||
@cell_fields ~w(
|
@cell_fields ~w(
|
||||||
temperature dewpoint_depression surface_rh surface_pressure_mb
|
temperature dewpoint_depression surface_rh surface_pressure_mb
|
||||||
surface_refractivity refractivity_gradient bl_height pwat
|
surface_refractivity refractivity_gradient bl_height pwat
|
||||||
|
|
@ -16,28 +13,6 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
ducting duct_base_m duct_strength
|
ducting duct_base_m duct_strength
|
||||||
)a
|
)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()
|
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||||
def cells(conn, %{"time" => time_param} = params) do
|
def cells(conn, %{"time" => time_param} = params) do
|
||||||
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
|
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
|
{:ok, layers} <- parse_layers(params["layers"]) do
|
||||||
rows = Weather.weather_grid_at(valid_time, bounds)
|
rows = Weather.weather_grid_at(valid_time, bounds)
|
||||||
|
|
||||||
case params["format"] do
|
conn
|
||||||
"bin" ->
|
|> put_resp_header("content-type", "application/octet-stream")
|
||||||
conn
|
|> put_resp_header("cache-control", "public, max-age=60")
|
||||||
|> put_resp_header("content-type", "application/octet-stream")
|
|> send_resp(200, encode_binary(rows, layers))
|
||||||
|> 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
|
else
|
||||||
_ -> bad_request(conn)
|
_ -> bad_request(conn)
|
||||||
end
|
end
|
||||||
|
|
@ -93,12 +57,6 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
|
|
||||||
defp parse_float(_), do: :error
|
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(nil), do: {:ok, @cell_fields}
|
||||||
|
|
||||||
defp parse_layers(value) when is_binary(value) do
|
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)
|
pad_size = padding_to_align(byte_size(header), 4)
|
||||||
header_padded = header <> :binary.copy(<<0>>, pad_size)
|
header_padded = header <> :binary.copy(<<0>>, pad_size)
|
||||||
|
|
||||||
lats = rows |> Enum.map(&<<float_or_nan(&1.lat)::little-float-32>>) |> IO.iodata_to_binary()
|
lats = rows |> Enum.map(&encode_f32(&1.lat)) |> IO.iodata_to_binary()
|
||||||
lons = rows |> Enum.map(&<<float_or_nan(&1.lon)::little-float-32>>) |> IO.iodata_to_binary()
|
lons = rows |> Enum.map(&encode_f32(&1.lon)) |> IO.iodata_to_binary()
|
||||||
|
|
||||||
values =
|
values =
|
||||||
layers
|
layers
|
||||||
|> Enum.map(fn field ->
|
|> Enum.map(fn field ->
|
||||||
Enum.map(rows, fn row ->
|
Enum.map(rows, fn row -> encode_value_f32(Map.get(row, field)) end)
|
||||||
<<encode_value(Map.get(row, field))::little-float-32>>
|
|
||||||
end)
|
|
||||||
end)
|
end)
|
||||||
|> IO.iodata_to_binary()
|
|> IO.iodata_to_binary()
|
||||||
|
|
||||||
|
|
@ -172,12 +128,16 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
if rem == 0, do: 0, else: align - rem
|
if rem == 0, do: 0, else: align - rem
|
||||||
end
|
end
|
||||||
|
|
||||||
defp encode_value(nil), do: :nan
|
# f32 quiet NaN bit pattern in little-endian — used for null values so
|
||||||
defp encode_value(true), do: 1.0
|
# the JS side can check Number.isNaN on a Float32Array entry.
|
||||||
defp encode_value(false), do: 0.0
|
@f32_nan <<0::8, 0::8, 0xC0::8, 0x7F::8>>
|
||||||
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 encode_value_f32(nil), do: @f32_nan
|
||||||
defp float_or_nan(_), do: :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
|
end
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
|
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_selected_time: initial_vt && DateTime.to_iso8601(initial_vt),
|
||||||
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
|
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
|
||||||
weather_tile_url: "/weather/tiles",
|
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
radar_visible: false
|
radar_visible: false
|
||||||
)}
|
)}
|
||||||
|
|
@ -214,7 +213,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
data-selected-layer={@selected_layer}
|
data-selected-layer={@selected_layer}
|
||||||
data-valid-times={@initial_valid_times_json}
|
data-valid-times={@initial_valid_times_json}
|
||||||
data-selected-time={@initial_selected_time}
|
data-selected-time={@initial_selected_time}
|
||||||
data-weather-tile-url={@weather_tile_url}
|
|
||||||
class="absolute inset-0 z-0"
|
class="absolute inset-0 z-0"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,6 @@ defmodule MicrowavepropWeb.Router do
|
||||||
|
|
||||||
get "/", PageController, :home
|
get "/", PageController, :home
|
||||||
get "/api/contacts/map", ContactMapController, :show
|
get "/api/contacts/map", ContactMapController, :show
|
||||||
get "/weather/tiles/:z/:x/:y", WeatherTileController, :show
|
|
||||||
get "/weather/cells", WeatherTileController, :cells
|
get "/weather/cells", WeatherTileController, :cells
|
||||||
|
|
||||||
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
|
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
|
||||||
|
|
|
||||||
|
|
@ -15,43 +15,13 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
||||||
:ok
|
:ok
|
||||||
end
|
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
|
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]
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
||||||
|
|
||||||
ProfilesFile.write!(valid_time, %{
|
ProfilesFile.write!(valid_time, %{
|
||||||
|
|
@ -62,14 +32,6 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
||||||
hpbl_m: 800.0,
|
hpbl_m: 800.0,
|
||||||
pwat_mm: 25.0,
|
pwat_mm: 25.0,
|
||||||
profile: []
|
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"
|
~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")
|
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
|
||||||
body = json_response(conn, 200)
|
body = response(conn, 200)
|
||||||
|
assert <<"WCEL", 1::8, _cell_count::little-32, _layer_count::8, _rest::binary>> = body
|
||||||
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
|
end
|
||||||
|
|
||||||
test "returns 400 for missing or invalid params", %{conn: conn} do
|
test "with ?layers=foo,bar limits the binary pack to those layers", %{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]
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
||||||
|
|
||||||
ProfilesFile.write!(valid_time, %{
|
ProfilesFile.write!(valid_time, %{
|
||||||
|
|
@ -173,30 +75,40 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
||||||
conn =
|
conn =
|
||||||
get(
|
get(
|
||||||
conn,
|
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")
|
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
|
||||||
body = response(conn, 200)
|
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 <<"WCEL", 1::8, cell_count::little-32, 2::8, rest::binary>> = body
|
||||||
assert cell_count == 2
|
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
|
<<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.
|
# Header so far: 4(magic) + 1 + 4 + 1 + (1+11) + (1+4) = 27 bytes. One
|
||||||
# Pad to 4-byte boundary: 1 byte of zero padding → 28 bytes.
|
# byte of zero padding aligns the f32 arrays to a 4-byte boundary.
|
||||||
<<0::8, lats_and_more::binary>> = padded_rest
|
<<0::8, lats_and_more::binary>> = padded_rest
|
||||||
|
|
||||||
<<lat1::little-float-32, lat2::little-float-32, rest_after_lats::binary>> =
|
<<lat1::little-float-32, lat2::little-float-32, _rest::binary>> = lats_and_more
|
||||||
lats_and_more
|
|
||||||
|
|
||||||
assert_in_delta lat1, 33.0, 0.001
|
assert_in_delta lat1, 33.0, 0.001
|
||||||
assert_in_delta lat2, 33.125, 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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -216,11 +216,12 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "tile overlay bootstrapping" do
|
describe "overlay bootstrapping" do
|
||||||
test "renders the weather tile base URL on the map element", %{conn: conn} do
|
test "seeds the weather map element with the initial valid_time", %{conn: conn} do
|
||||||
{:ok, _lv, html} = live(conn, ~p"/weather")
|
{: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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue