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 --%>