diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 2f0b1fcb..ebbf4225 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -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 | 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 + 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 -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(``) + } + + 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) { diff --git a/lib/microwaveprop_web/controllers/weather_tile_controller.ex b/lib/microwaveprop_web/controllers/weather_tile_controller.ex index 4ce0830a..bd4b0f3e 100644 --- a/lib/microwaveprop_web/controllers/weather_tile_controller.ex +++ b/lib/microwaveprop_web/controllers/weather_tile_controller.ex @@ -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 diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index be35ca8c..f2933c0b 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -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 diff --git a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs index 43cff57c..84ddde37 100644 --- a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs +++ b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs @@ -49,4 +49,69 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do assert body =~ " %{ + 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