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