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(), - render_rows(rows, layer_id, z, x, y), - "" - ]) - 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) =~ " %{ - 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 - <> = - lats_and_more - + <> = 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 diff --git a/test/microwaveprop_web/live/weather_map_live_test.exs b/test/microwaveprop_web/live/weather_map_live_test.exs index a7b4681e..c796d456 100644 --- a/test/microwaveprop_web/live/weather_map_live_test.exs +++ b/test/microwaveprop_web/live/weather_map_live_test.exs @@ -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