From 725a7e6bda26fad86adfee98b5dcb2949cb03533 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 15 Apr 2026 13:57:14 -0500 Subject: [PATCH] WeatherMap: forecast-hour timeline + top-right layer description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related UI changes driven by the same data: * Move the selected-layer description out of the sidebar and into a dedicated top-right overlay on the map. The sidebar kept the same layer buttons but dropped the descriptive text; the new overlay shows group + layer name + description in one card. Mobile still shows the description in the collapsible panel since it has no top-right free real estate. * Add a forecast-hour timeline bar at the bottom of the map, matching the propagation map. WeatherMapLive enumerates ProfilesFile on mount (the grid worker has been persisting f00..f18 on disk since commit 07ffcf5), pushes data-valid-times for the JS hook to render as buttons, and handles a new select_time event by reading the per-hour ProfilesFile on demand via Weather.weather_grid_at/2. weather_grid_at/2 deliberately skips the GridCache write path for non-latest hours — caching 18 × 92k rows would add ~300 MB per pod. A ~2 MB ETF decode per scrub is fast enough for a click. Subscribes to propagation:pipeline so the timeline picks up new forecast hours live as they land, without waiting for the full chain to finish. --- assets/js/weather_map_hook.ts | 116 ++++++++++++++++ .../propagation/profiles_file.ex | 12 ++ lib/microwaveprop/weather.ex | 41 ++++++ .../live/weather_map_live.ex | 130 ++++++++++++++++-- .../propagation/profiles_file_test.exs | 16 +++ .../live/weather_map_live_test.exs | 114 +++++++++++++++ 6 files changed, 417 insertions(+), 12 deletions(-) create mode 100644 test/microwaveprop_web/live/weather_map_live_test.exs diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 50ff031d..25da1bd6 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -85,6 +85,9 @@ interface WeatherMapHook extends ViewHook { gridVisible: boolean radarLayer: L.TileLayer.WMS | null radarRefreshTimer: ReturnType | null + timelineEl: HTMLElement | null + timelineData: string[] + selectedTime: string | null _escHandler: (e: KeyboardEvent) => void _layerObserver: MutationObserver @@ -95,6 +98,8 @@ interface WeatherMapHook extends ViewHook { buildLookup(this: WeatherMapHook): void renderLayer(this: WeatherMapHook): void buildLegend(this: WeatherMapHook): HTMLElement + renderTimeline(this: WeatherMapHook): void + selectTimelineTime(this: WeatherMapHook, time: string): void } // --- Helpers --- @@ -513,6 +518,34 @@ export const WeatherMap: WeatherMapHook = { this.legend = L.control({ position: isMobile() ? "topright" : "bottomright" }) this.legend.onAdd = () => this.buildLegend() this.legend.addTo(this.map) + + // --- Forecast timeline --- + this.timelineEl = document.getElementById("weather-forecast-timeline") + this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]") + this.selectedTime = this.el.dataset.selectedTime || null + + if (this.timelineData.length > 1) { + this.renderTimeline() + } + + this.handleEvent("update_timeline", ({ times, selected }: { times: string[]; selected: string | null }) => { + this.timelineData = times + if (selected) this.selectedTime = selected + this.renderTimeline() + }) + + // Prevent timeline from eating map clicks + if (this.timelineEl) { + L.DomEvent.disableClickPropagation(this.timelineEl) + L.DomEvent.disableScrollPropagation(this.timelineEl) + } + + // Keep the layer-info overlay from passing through to the map. + const layerInfo = document.getElementById("weather-layer-info") + if (layerInfo) { + L.DomEvent.disableClickPropagation(layerInfo) + L.DomEvent.disableScrollPropagation(layerInfo) + } }, destroyed(this: WeatherMapHook) { @@ -653,5 +686,88 @@ export const WeatherMap: WeatherMapHook = { ).join("
") } return div + }, + + renderTimeline(this: WeatherMapHook) { + if (!this.timelineEl || this.timelineData.length === 0) { + if (this.timelineEl) this.timelineEl.style.display = "none" + return + } + + if (this.timelineData.length === 1) { + const dt = new Date(this.timelineData[0]) + const utcLabel = `${dt.getUTCHours().toString().padStart(2, "0")}:00 UTC` + const dateLabel = `${dt.getUTCFullYear()}-${(dt.getUTCMonth() + 1).toString().padStart(2, "0")}-${dt.getUTCDate().toString().padStart(2, "0")}` + this.timelineEl.style.display = "block" + this.timelineEl.innerHTML = ` +
+ Data: ${dateLabel} ${utcLabel} +
` + return + } + + const now = new Date() + type TItem = { time: string; dt: Date; isSelected: boolean; isPast: boolean; offsetH: number; label: string } + const items: TItem[] = this.timelineData.map((time) => { + const dt = new Date(time) + const isSelected = time === this.selectedTime + const isPast = dt.getTime() < now.getTime() - 1800000 + const offsetH = Math.round((dt.getTime() - now.getTime()) / 3600000) + return { time, dt, isSelected, isPast, offsetH, label: "" } + }) + + const closestIdx = items.reduce((best, item, i) => + Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0) + + items.forEach((t, i) => { + if (i === closestIdx) { + t.label = "Now" + } else { + const diff = t.offsetH + t.label = diff > 0 ? `+${diff}h` : `${diff}h` + } + }) + + const mobile = isMobile() + const btnPad = mobile ? "3px 5px" : "4px 8px" + const btnMinW = mobile ? "32px" : "38px" + const btnFont = mobile ? "10px" : "11px" + const subFont = mobile ? "8px" : "9px" + + const buttons = items.map(t => { + const bg = t.isSelected ? "#7dffd4" : (t.isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)") + const fg = t.isSelected ? "#000" : (t.isPast ? "rgba(255,255,255,0.4)" : "#fff") + const border = t.isSelected ? "2px solid #7dffd4" : "1px solid rgba(255,255,255,0.2)" + const weight = t.isSelected ? "700" : "400" + const utcLabel = `${t.dt.getUTCHours().toString().padStart(2, "0")}:00` + return `` + }).join("") + + const labelText = mobile ? "Forecast" : "Weather Forecast" + + this.timelineEl.style.display = "block" + this.timelineEl.innerHTML = ` +
+ ${labelText} + ${buttons} +
` + + this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => { + btn.addEventListener("click", () => { + const time = btn.dataset.time! + this.selectTimelineTime(time) + }) + }) + }, + + selectTimelineTime(this: WeatherMapHook, time: string) { + this.selectedTime = time + this.renderTimeline() + this.pushEvent("select_time", { time }) } } as unknown as WeatherMapHook diff --git a/lib/microwaveprop/propagation/profiles_file.ex b/lib/microwaveprop/propagation/profiles_file.ex index 23986d5c..c159866d 100644 --- a/lib/microwaveprop/propagation/profiles_file.ex +++ b/lib/microwaveprop/propagation/profiles_file.ex @@ -154,6 +154,18 @@ defmodule Microwaveprop.Propagation.ProfilesFile do end end + @doc """ + Every persisted valid_time, sorted ascending. Used to build the + /weather forecast timeline from the on-disk f00..f18 profile files. + """ + @spec list_valid_times() :: [DateTime.t()] + def list_valid_times do + base_dir() + |> list_profile_files() + |> Enum.map(fn {_path, unix} -> DateTime.from_unix!(unix) end) + |> Enum.sort(DateTime) + end + defp snap(lat, lon) do step = Grid.step() snapped_lat = Float.round(Float.round(lat / step) * step, 3) diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index 14e73394..ac099a44 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -435,6 +435,47 @@ defmodule Microwaveprop.Weather do end end + @doc """ + All persisted weather valid_times sorted ascending. The grid worker + writes a ProfilesFile for every forecast hour (f00..f18), so this + enumerates the 19-entry forecast timeline the /weather page renders + at the bottom of the map. + """ + @spec available_weather_valid_times() :: [DateTime.t()] + def available_weather_valid_times do + ProfilesFile.list_valid_times() + end + + @doc """ + Read the weather grid for a specific `valid_time` and bounds. Like + `load_weather_grid/1` but takes the valid_time explicitly so the + timeline can scrub to any forecast hour, not just the analysis hour. + Returns `[]` if no profile file exists for that valid_time. + + Deliberately does NOT write forecast-hour grids back into `GridCache` + on a miss: caching 18 forecast hours × 92k points would add ~300 MB + per pod. The ProfilesFile read is a single ~2 MB ETF decode per scrub, + which is fast enough for a user click. + """ + @spec weather_grid_at(DateTime.t(), map()) :: [map()] + def weather_grid_at(%DateTime{} = valid_time, bounds) do + case GridCache.fetch_bounds(valid_time, bounds) do + {:ok, rows} -> + rows + + :miss -> + case ProfilesFile.read(valid_time) do + {:ok, grid_data} -> + grid_data + |> build_grid_cache_rows(valid_time) + |> filter_weather_bounds(bounds) + + {:error, _} -> + [] + end + end + end + # Build derived GridCache rows for a valid_time from whichever # source has data: the persisted ProfilesFile first (hot path in # steady state), then the legacy hrrr_profiles table (historical diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index 5e9b23ea..b840fe21 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -140,10 +140,16 @@ defmodule MicrowavepropWeb.WeatherMapLive do def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated") + Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline") end - data = Weather.latest_weather_grid(@initial_bounds) - valid_time = if data != [], do: hd(data).valid_time + valid_times = Weather.available_weather_valid_times() + selected_time = closest_to_now(valid_times) + + data = + if selected_time, + do: Weather.weather_grid_at(selected_time, @initial_bounds), + else: [] {:ok, assign(socket, @@ -151,7 +157,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do layers: @layers, selected_layer: "refractivity_gradient", initial_data_json: Jason.encode!(data), - valid_time: valid_time, + valid_time: selected_time, + valid_times: valid_times, + initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)), + initial_selected_time: selected_time && DateTime.to_iso8601(selected_time), initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"), bounds: @initial_bounds, grid_visible: false, @@ -159,6 +168,14 @@ defmodule MicrowavepropWeb.WeatherMapLive do )} end + defp closest_to_now([]), do: nil + + defp closest_to_now(valid_times) do + now = DateTime.utc_now() + + Enum.min_by(valid_times, fn t -> abs(DateTime.diff(t, now, :second)) end) + end + @impl true def handle_event("select_layer", %{"layer" => layer_id}, socket) do {:noreply, assign(socket, :selected_layer, layer_id)} @@ -187,7 +204,11 @@ defmodule MicrowavepropWeb.WeatherMapLive do end def handle_event("map_bounds", bounds, socket) do - data = Weather.latest_weather_grid(bounds) + data = + case socket.assigns.valid_time do + nil -> Weather.latest_weather_grid(bounds) + vt -> Weather.weather_grid_at(vt, bounds) + end socket = socket @@ -197,6 +218,22 @@ defmodule MicrowavepropWeb.WeatherMapLive do {:noreply, socket} end + def handle_event("select_time", %{"time" => time_str}, socket) do + with {:ok, time, _} <- DateTime.from_iso8601(time_str), + true <- time in socket.assigns.valid_times do + data = Weather.weather_grid_at(time, socket.assigns.bounds) + + socket = + socket + |> assign(:valid_time, time) + |> push_event("update_weather", %{data: data}) + + {:noreply, socket} + else + _ -> {:noreply, socket} + end + end + def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do detail = if socket.assigns.valid_time do @@ -209,17 +246,50 @@ defmodule MicrowavepropWeb.WeatherMapLive do @impl true def handle_info({:weather_updated, _valid_time}, socket) do - data = Weather.latest_weather_grid(socket.assigns.bounds) - valid_time = if data != [], do: hd(data).valid_time + valid_times = Weather.available_weather_valid_times() + + selected = + if socket.assigns.valid_time in valid_times, + do: socket.assigns.valid_time, + else: closest_to_now(valid_times) + + data = + if selected, do: Weather.weather_grid_at(selected, socket.assigns.bounds), else: [] socket = socket - |> assign(:valid_time, valid_time) + |> assign(:valid_times, valid_times) + |> assign(:valid_time, selected) |> push_event("update_weather", %{data: data}) + |> push_event("update_timeline", %{ + times: Enum.map(valid_times, &DateTime.to_iso8601/1), + selected: selected && DateTime.to_iso8601(selected) + }) {:noreply, socket} end + # A new forecast hour has landed. Re-enumerate the available times + # so the timeline picks up the extra hour, but keep the currently + # selected time where it is. + def handle_info({:propagation_pipeline_progress, _progress}, socket) do + valid_times = Weather.available_weather_valid_times() + + if valid_times == socket.assigns.valid_times do + {:noreply, socket} + else + socket = + socket + |> assign(:valid_times, valid_times) + |> push_event("update_timeline", %{ + times: Enum.map(valid_times, &DateTime.to_iso8601/1), + selected: socket.assigns.valid_time && DateTime.to_iso8601(socket.assigns.valid_time) + }) + + {:noreply, socket} + end + end + @group_order ["Surface", "Upper Air", "Ducting"] defp group_layers(layers) do @@ -235,6 +305,20 @@ defmodule MicrowavepropWeb.WeatherMapLive do end end + defp selected_layer_label(layers, selected_id) do + case Enum.find(layers, &(&1.id == selected_id)) do + %{label: label} -> label + _ -> "" + end + end + + defp selected_layer_group_label(layers, selected_id) do + case Enum.find(layers, &(&1.id == selected_id)) do + %{group: group} -> group + _ -> "" + end + end + @impl true def render(assigns) do ~H""" @@ -249,10 +333,37 @@ defmodule MicrowavepropWeb.WeatherMapLive do data-weather={@initial_data_json} data-layers={Jason.encode!(@layers)} data-selected-layer={@selected_layer} + data-valid-times={@initial_valid_times_json} + data-selected-time={@initial_selected_time} class="absolute inset-0 z-0" > + <%!-- Layer description overlay (top-right of map area) --%> + + + <%!-- Bottom forecast timeline --%> +
+
+ <%!-- Mobile-only floating controls --%>
- <%!-- Layer description --%> -
- {layer_description(@layers, @selected_layer)} -
- <%!-- Overlay toggles --%>