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:
parent
b67c0e88c0
commit
f4a177c9d0
4 changed files with 315 additions and 43 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue