diff --git a/lib/microwaveprop/weather/tile_renderer.ex b/lib/microwaveprop/weather/tile_renderer.ex
deleted file mode 100644
index 03324161..00000000
--- a/lib/microwaveprop/weather/tile_renderer.ex
+++ /dev/null
@@ -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("
- ])
- end
-
- @spec empty_svg() :: binary()
- def empty_svg do
- ~s()
- 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()
- 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
diff --git a/lib/microwaveprop_web/controllers/weather_tile_controller.ex b/lib/microwaveprop_web/controllers/weather_tile_controller.ex
index 2b80b3dc..0552c35c 100644
--- a/lib/microwaveprop_web/controllers/weather_tile_controller.ex
+++ b/lib/microwaveprop_web/controllers/weather_tile_controller.ex
@@ -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(&<>) |> IO.iodata_to_binary()
- lons = rows |> Enum.map(&<>) |> 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 ->
- <>
- 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: <>
+ defp encode_value_f32(_), do: @f32_nan
+
+ defp encode_f32(v) when is_number(v), do: <>
+ defp encode_f32(_), do: @f32_nan
end
diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex
index 4409a32b..97b4911f 100644
--- a/lib/microwaveprop_web/live/weather_map_live.ex
+++ b/lib/microwaveprop_web/live/weather_map_live.ex
@@ -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"
>
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index f2933c0b..19ae2264 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -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
diff --git a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs
index 454cf0ea..4038fb85 100644
--- a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs
+++ b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs
@@ -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) =~ "