prop/lib/microwaveprop_web/controllers/weather_tile_controller.ex
Graham McIntire f4a177c9d0
perf(weather): single-fetch viewport cells, client-side recolor on layer switch
Previously every layer toggle triggered 21 fresh tile requests with the
new layer in the URL — ~21 server-side SVG renders, plus the round-trip
overhead. Even with the GridCache fix the server still spent real CPU
generating thousands of <rect>s per tile, and the user felt it.

Add /weather/cells: a single JSON endpoint returning every layer field
for every cell in the requested viewport. The hook fetches once on
mount, time change, or pan/zoom, caches the cells in memory, and paints
them via L.svgOverlay. Layer switches now re-color the same cached
cells locally — zero network, zero server CPU.

The single SVG uses lat/lon coords with preserveAspectRatio="none";
Leaflet stretches it linearly to the bbox. There is mild equirect→
mercator distortion at low zoom over CONUS but it's imperceptible at
typical use (z6+) and the layer-toggle UX win is large.

Tile endpoint kept for back-compat. svgOverlay opacity matches the
prior tile renderer's 0.55 fill-opacity.
2026-04-29 13:31:17 -05:00

91 lines
2.9 KiB
Elixir

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.
@cell_fields ~w(
temperature dewpoint_depression surface_rh surface_pressure_mb
surface_refractivity refractivity_gradient bl_height pwat
temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb
lapse_rate mid_lapse_rate inversion_strength inversion_base_m
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),
{:ok, bounds} <- parse_bounds(params) do
rows = Weather.weather_grid_at(valid_time, bounds)
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)
})
else
_ -> bad_request(conn)
end
end
def cells(conn, _params), do: bad_request(conn)
defp bad_request(conn) do
conn
|> put_status(400)
|> json(%{error: "invalid params"})
end
defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
with {:ok, south} <- parse_float(s),
{:ok, north} <- parse_float(n),
{:ok, west} <- parse_float(w),
{:ok, east} <- parse_float(e) do
{:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}}
end
end
defp parse_bounds(_), do: :error
defp parse_float(value) when is_binary(value) do
case Float.parse(value) do
{f, ""} -> {:ok, f}
_ -> :error
end
end
defp parse_float(_), do: :error
defp cell_payload(row) do
Enum.reduce(@cell_fields, %{lat: row.lat, lon: row.lon}, fn field, acc ->
Map.put(acc, field, Map.get(row, field))
end)
end
end