Move the /weather render path off raw HRRR profile decoding + per-cell SoundingParams + WeatherLayers derivation. The dominant cost was re-deriving the same scalar fields on every viewport pan and timeline scrub. Server side: - New Microwaveprop.Weather.ScalarFile persists pre-derived scalar rows on NFS, bucketed into 5°×5° chunk files under <base>/weather_scalars/<iso>/<lat_band>_<lon_band>.etf.gz. Viewport reads only decode the chunks that overlap the requested bounds; point-detail clicks read exactly one chunk. - weather_grid_at/2 and weather_point_detail/3 prefer ScalarFile; ProfilesFile is now the cold-start fallback only. - warm_grid_cache_and_broadcast/1 and warm_grid_cache_from_latest_profile/0 also persist the scalar artifact, and the cold-derive path kicks off a per-valid_time-locked async materialization so the next reader gets the cheap path. - NotifyListener.handle_propagation_ready/1 fires Weather.materialize_scalar_file/1 in a detached Task on the Rust pipeline's NOTIFY propagation_ready, so steady-state forecast hours arrive with their scalar artifact already on disk. - available_weather_valid_times/0 unions ScalarFile + ProfilesFile so the timeline survives an aggressive retention sweep on either side. Browser side (finding #8): - Mount the legend Leaflet control once and patch its inner content on layer changes instead of remove + re-add on every renderLayer. - renderTimeline now keys off the rendered button set: selection-only updates take an applyTimelineSelection patch path that restyles existing buttons in place. Full innerHTML rebuild only fires when timelineData itself changes. Also fixes a credo nesting-depth flag in tile_renderer.ex by extracting interpolate_pair/2 from the inner anonymous function.
290 lines
7.7 KiB
Elixir
290 lines
7.7 KiB
Elixir
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
|