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.
This commit is contained in:
Graham McIntire 2026-04-29 13:31:17 -05:00
parent b67c0e88c0
commit f4a177c9d0
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 315 additions and 43 deletions

View file

@ -67,11 +67,41 @@ interface DetailRow {
separator?: boolean
}
interface ViewportCell {
lat: number
lon: number
temperature: number | null
dewpoint_depression: number | null
surface_rh: number | null
surface_pressure_mb: number | null
surface_refractivity: number | null
refractivity_gradient: number | null
bl_height: number | null
pwat: number | null
temp_850mb: number | null
dewpoint_850mb: number | null
temp_700mb: number | null
dewpoint_700mb: number | null
lapse_rate: number | null
mid_lapse_rate: number | null
inversion_strength: number | null
inversion_base_m: number | null
ducting: boolean | null
duct_base_m: number | null
duct_strength: number | null
}
interface WeatherMapHook extends ViewHook {
map: L.Map
weatherOverlay: L.TileLayer | null
weatherTileUrl: string
weatherOverlay: L.SVGOverlay | null
weatherCellsUrl: string
selectedLayer: LayerId
// Cells most recently fetched. Layer switches re-render from this
// without going to the server.
currentCells: ViewportCell[]
cellsRequestSeq: number
inflightCellsAbort: AbortController | null
moveDebounce: ReturnType<typeof setTimeout> | null
detailPanel: HTMLElement | null
escHint: HTMLDivElement
clickMarker: L.LayerGroup
@ -98,7 +128,8 @@ interface WeatherMapHook extends ViewHook {
reconnected(this: WeatherMapHook): void
positionDetailPanel(this: WeatherMapHook): void
renderLayer(this: WeatherMapHook): void
overlayUrl(this: WeatherMapHook): string | null
fetchAndPaintCells(this: WeatherMapHook, opts?: { force?: boolean }): Promise<void>
paintOverlay(this: WeatherMapHook): void
refreshLegend(this: WeatherMapHook): void
buildLegendContent(this: WeatherMapHook): string
renderTimeline(this: WeatherMapHook): void
@ -336,15 +367,53 @@ const COLOR_SCALES = {
}
} as const satisfies Record<string, ColorScale>
function buildWeatherTileUrl(baseUrl: string, layerId: LayerId, selectedTime: string | null): string | null {
if (!selectedTime) return null
// Round a lat/lon viewport edge out to the cell-grid resolution so two
// nearby pans hit the same fetch URL (and the browser cache).
function quantizeBounds(b: L.LatLngBounds, pad = 0.5): { south: number; north: number; west: number; east: number } {
const south = Math.floor((b.getSouth() - pad) * 8) / 8
const north = Math.ceil((b.getNorth() + pad) * 8) / 8
const west = Math.floor((b.getWest() - pad) * 8) / 8
const east = Math.ceil((b.getEast() + pad) * 8) / 8
return { south, north, west, east }
}
const params = new URLSearchParams({
layer: layerId,
time: selectedTime
})
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
return `${baseUrl}/{z}/{x}/{y}?${params.toString()}`
function interpolateRgb(value: number, bps: Breakpoint[]): RGB {
if (value <= bps[0].value) return { r: bps[0].r, g: bps[0].g, b: bps[0].b }
const last = bps[bps.length - 1]
if (value >= last.value) return { r: last.r, g: last.g, b: last.b }
for (let i = 0; i < bps.length - 1; i++) {
const a = bps[i]
const b = bps[i + 1]
if (value >= a.value && value <= b.value) {
const t = (value - a.value) / (b.value - a.value)
return {
r: Math.round(lerp(a.r, b.r, t)),
g: Math.round(lerp(a.g, b.g, t)),
b: Math.round(lerp(a.b, b.b, t))
}
}
}
return { r: last.r, g: last.g, b: last.b }
}
function colorForCell(cell: ViewportCell, layerId: LayerId): string | null {
const scale = COLOR_SCALES[layerId]
if (!scale) return null
const value = cell[layerId as keyof ViewportCell]
if (scale.breakpoints === null) {
if (value !== true) return null
const c = scale.trueColor
return `rgb(${c.r},${c.g},${c.b})`
}
if (typeof value !== "number" || !Number.isFinite(value)) return null
const c = interpolateRgb(value, scale.breakpoints)
return `rgb(${c.r},${c.g},${c.b})`
}
function buildDetailHTML(point: WeatherPoint): string {
@ -427,8 +496,12 @@ export const WeatherMap: WeatherMapHook = {
}).addTo(this.map)
this.weatherOverlay = null
this.weatherTileUrl = this.el.dataset.weatherTileUrl || "/weather/tiles"
this.weatherCellsUrl = "/weather/cells"
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
this.currentCells = []
this.cellsRequestSeq = 0
this.inflightCellsAbort = null
this.moveDebounce = null
const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.selectedLayer) {
@ -436,17 +509,17 @@ export const WeatherMap: WeatherMapHook = {
this.pushEvent("select_layer", { layer: storedLayer })
}
// Seed selectedTime/timelineData from the dataset before the first
// renderLayer call. Without this, buildWeatherTileUrl returns null
// and no overlay paints until the user clicks a forecast button.
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
this.selectedTime = this.el.dataset.selectedTime || null
this.renderLayer()
// Initial paint — fetches cells for the current viewport + time and
// paints them via L.svgOverlay. Layer switches afterwards re-color
// these same cells client-side without going to the server.
this.fetchAndPaintCells()
this.handleEvent("update_weather_overlay", ({ selected }: { selected: string | null }) => {
this.selectedTime = selected
this.renderLayer()
this.fetchAndPaintCells({ force: true })
})
this.detailPanel = document.getElementById("weather-detail-panel")
@ -561,14 +634,17 @@ export const WeatherMap: WeatherMapHook = {
if (this.gridVisible) {
updateGridOverlay(this.map, this.gridLayer)
}
// Debounce so a rapid pan-zoom only fires one fetch.
if (this.moveDebounce) clearTimeout(this.moveDebounce)
this.moveDebounce = setTimeout(() => this.fetchAndPaintCells(), 250)
})
// When the tab becomes visible again, refresh the current overlay tiles
// for the now-current viewport and map size.
// When the tab becomes visible again, refresh the overlay for the
// now-current viewport.
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
this.map.invalidateSize()
this.renderLayer()
this.fetchAndPaintCells()
}
}
document.addEventListener("visibilitychange", this.visibilityHandler)
@ -578,7 +654,9 @@ export const WeatherMap: WeatherMapHook = {
if (newLayer && newLayer !== this.selectedLayer) {
this.selectedLayer = newLayer
localStorage.setItem("weatherMap.selectedLayer", newLayer)
this.renderLayer()
// Layer switch: re-color the cells we already have. No network.
this.paintOverlay()
this.refreshLegend()
}
})
this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] })
@ -610,10 +688,11 @@ export const WeatherMap: WeatherMapHook = {
// the rendered key so renderTimeline doesn't take the
// selection-only patch path against stale buttons.
const dataChanged = times.join("|") !== this.timelineRenderedKey
const timeChanged = selected !== this.selectedTime
this.timelineData = times
this.selectedTime = selected
if (dataChanged) this.timelineRenderedKey = ""
this.renderLayer()
if (timeChanged) this.fetchAndPaintCells({ force: true })
this.renderTimeline()
})
@ -629,6 +708,8 @@ export const WeatherMap: WeatherMapHook = {
if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
if (this.moveDebounce) clearTimeout(this.moveDebounce)
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
this.visibilityHandler = null
@ -638,7 +719,7 @@ export const WeatherMap: WeatherMapHook = {
reconnected(this: WeatherMapHook) {
if (this.map) {
this.map.invalidateSize()
this.renderLayer()
this.fetchAndPaintCells()
}
},
@ -652,37 +733,99 @@ export const WeatherMap: WeatherMapHook = {
}
},
overlayUrl(this: WeatherMapHook) {
return buildWeatherTileUrl(this.weatherTileUrl, this.selectedLayer, this.selectedTime)
renderLayer(this: WeatherMapHook) {
// Kept for back-compat with code paths that still call it. The
// server fetch + paint pipeline lives in fetchAndPaintCells; layer
// switches go through paintOverlay directly.
this.paintOverlay()
this.refreshLegend()
},
renderLayer(this: WeatherMapHook) {
const layerId = this.selectedLayer
const scale = COLOR_SCALES[layerId]
if (!scale) return
async fetchAndPaintCells(this: WeatherMapHook, opts: { force?: boolean } = {}) {
if (!this.selectedTime) {
this.currentCells = []
this.paintOverlay()
return
}
const tileUrl = this.overlayUrl()
const seq = ++this.cellsRequestSeq
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
this.inflightCellsAbort = new AbortController()
if (!tileUrl) {
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
const url = `${this.weatherCellsUrl}?time=${encodeURIComponent(this.selectedTime)}&south=${south}&north=${north}&west=${west}&east=${east}`
try {
const resp = await fetch(url, {
signal: this.inflightCellsAbort.signal,
cache: opts.force ? "no-cache" : "default"
})
if (!resp.ok) return
const data = await resp.json() as { cells: ViewportCell[]; valid_time: string }
// A newer fetch already started — drop this stale response so we
// don't paint over the in-flight one.
if (seq !== this.cellsRequestSeq) return
this.currentCells = data.cells
this.paintOverlay()
} catch (e) {
const err = e as Error
if (err.name === "AbortError") return
console.warn("weather cells fetch failed", err)
}
},
paintOverlay(this: WeatherMapHook) {
const cells = this.currentCells
if (!cells || cells.length === 0) {
if (this.weatherOverlay) {
this.map.removeLayer(this.weatherOverlay)
this.weatherOverlay = null
}
} else if (this.weatherOverlay) {
this.weatherOverlay.setUrl(tileUrl, false)
this.weatherOverlay.redraw()
} else {
this.weatherOverlay = L.tileLayer(tileUrl, {
tileSize: 256,
opacity: 1.0,
updateWhenIdle: true,
keepBuffer: 1,
bounds: [[-85.0511, -180], [85.0511, 180]]
})
this.weatherOverlay.addTo(this.map)
return
}
this.refreshLegend()
const halfStep = 0.0625
const layerId = this.selectedLayer
let south = Infinity
let north = -Infinity
let west = Infinity
let east = -Infinity
for (const c of cells) {
if (c.lat < south) south = c.lat
if (c.lat > north) north = c.lat
if (c.lon < west) west = c.lon
if (c.lon > east) east = c.lon
}
south -= halfStep
north += halfStep
west -= halfStep
east += halfStep
const parts: string[] = []
for (const cell of cells) {
const color = colorForCell(cell, layerId)
if (!color) continue
const x = (cell.lon - halfStep).toFixed(4)
// SVG y grows downward, so flip latitude.
const y = (-cell.lat - halfStep).toFixed(4)
parts.push(`<rect x="${x}" y="${y}" width="0.125" height="0.125" fill="${color}" />`)
}
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg")
svgEl.setAttribute("viewBox", `${west} ${-north} ${east - west} ${north - south}`)
svgEl.setAttribute("preserveAspectRatio", "none")
svgEl.setAttribute("shape-rendering", "crispEdges")
svgEl.innerHTML = parts.join("")
const bounds: L.LatLngBoundsExpression = [[south, west], [north, east]]
if (this.weatherOverlay) this.map.removeLayer(this.weatherOverlay)
this.weatherOverlay = L.svgOverlay(svgEl, bounds, { opacity: 0.55, interactive: false })
this.weatherOverlay.addTo(this.map)
},
refreshLegend(this: WeatherMapHook) {

View file

@ -1,9 +1,21 @@
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),
@ -25,4 +37,55 @@ defmodule MicrowavepropWeb.WeatherTileController do
|> 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

View file

@ -181,6 +181,7 @@ 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
live "/submit", SubmitLive

View file

@ -49,4 +49,69 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
assert body =~ "<svg"
refute body =~ "<rect"
end
describe "GET /weather/cells" do
test "returns viewport cells as JSON", %{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: []
},
{33.125, -97.0} => %{
surface_temp_c: 23.0,
surface_dewpoint_c: 13.0,
surface_pressure_mb: 1011.0,
hpbl_m: 700.0,
pwat_mm: 26.0,
profile: []
}
})
time = DateTime.to_iso8601(valid_time)
conn =
get(
conn,
~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")
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
end
end