WeatherMap: forecast-hour timeline + top-right layer description

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.
This commit is contained in:
Graham McIntire 2026-04-15 13:57:14 -05:00
parent cacce902d3
commit 725a7e6bda
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 417 additions and 12 deletions

View file

@ -85,6 +85,9 @@ interface WeatherMapHook extends ViewHook {
gridVisible: boolean
radarLayer: L.TileLayer.WMS | null
radarRefreshTimer: ReturnType<typeof setInterval> | 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("<br>")
}
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 = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 14px;display:flex;gap:8px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);font-size:11px;color:rgba(255,255,255,0.6);">
<span>Data: ${dateLabel} ${utcLabel}</span>
</div>`
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 `<button data-time="${t.time}" style="
padding:${btnPad};font-size:${btnFont};font-weight:${weight};
background:${bg};color:${fg};border:${border};border-radius:6px;
cursor:pointer;white-space:nowrap;min-width:${btnMinW};
transition:background 0.15s;
">${t.label}<br><span style="font-size:${subFont};opacity:0.7;">${utcLabel}</span></button>`
}).join("")
const labelText = mobile ? "Forecast" : "Weather Forecast"
this.timelineEl.style.display = "block"
this.timelineEl.innerHTML = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;margin-right:6px;flex-shrink:0;">${labelText}</span>
${buttons}
</div>`
this.timelineEl.querySelectorAll<HTMLButtonElement>("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

View file

@ -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)

View file

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

View file

@ -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"
>
</div>
<%!-- Layer description overlay (top-right of map area) --%>
<div
id="weather-layer-info"
data-theme="dark"
class="hidden md:block absolute top-3 right-3 z-[1000] max-w-sm bg-neutral text-neutral-content rounded-box border border-base-300 shadow-lg p-3"
>
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
{selected_layer_group_label(@layers, @selected_layer)} &middot; {selected_layer_label(
@layers,
@selected_layer
)}
</div>
<div class="text-xs opacity-80 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<%!-- Bottom forecast timeline --%>
<div
id="weather-forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<%!-- Mobile-only floating controls --%>
<div
id="weather-controls"
@ -455,11 +566,6 @@ defmodule MicrowavepropWeb.WeatherMapLive do
</div>
</div>
<%!-- Layer description --%>
<div class="text-[11px] opacity-70 px-1 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
<%!-- Overlay toggles --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">

View file

@ -58,6 +58,22 @@ defmodule Microwaveprop.Propagation.ProfilesFileTest do
end
end
describe "list_valid_times/0" do
test "returns all persisted valid_times sorted ascending" do
assert ProfilesFile.list_valid_times() == []
for vt <- [~U[2026-04-14 18:00:00Z], ~U[2026-04-14 10:00:00Z], ~U[2026-04-14 14:00:00Z]] do
ProfilesFile.write!(vt, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
end
assert ProfilesFile.list_valid_times() == [
~U[2026-04-14 10:00:00Z],
~U[2026-04-14 14:00:00Z],
~U[2026-04-14 18:00:00Z]
]
end
end
describe "latest_valid_time/0" do
test "returns the most recent persisted valid_time" do
assert ProfilesFile.latest_valid_time() == nil

View file

@ -0,0 +1,114 @@
defmodule MicrowavepropWeb.WeatherMapLiveTest do
# async: false because we write to the shared ProfilesFile directory
# which the WeatherMapLive reads at mount.
use MicrowavepropWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Weather.GridCache
setup do
GridCache.clear()
on_exit(fn ->
GridCache.clear()
# Scrub any profile files our tests wrote so other async tests
# that enumerate the ProfilesFile dir (e.g. latest_valid_time)
# aren't affected.
ProfilesFile.prune_older_than(~U[2099-12-31 23:59:59Z])
end)
:ok
end
defp write_profile(valid_time, lat, lon) do
profile =
for level <- [1000, 925, 850, 700], do: %{"pres" => level * 1.0, "tmpc" => 15.0, "dwpc" => 5.0}
grid_data = %{
{lat, lon} => %{
surface_temp_c: 22.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1010.0,
hpbl_m: 800.0,
pwat_mm: 25.0,
profile: profile
}
}
ProfilesFile.write!(valid_time, grid_data)
end
describe "layer description overlay" do
test "renders the description in a top-right overlay, not in the sidebar", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather")
assert html =~ ~s(id="weather-layer-info")
# Default layer is refractivity_gradient — its description should
# appear inside the overlay element.
assert html =~ ~r/id="weather-layer-info".*?Minimum refractivity gradient/s
end
end
describe "forecast timeline" do
test "mount pushes data-valid-times for every persisted profile", %{conn: conn} do
base =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second)
# Seed a profile file at each forecast hour.
Enum.each(times, &write_profile(&1, 33.0, -97.0))
{:ok, _lv, html} = live(conn, ~p"/weather")
# Timeline container exists.
assert html =~ ~s(id="weather-forecast-timeline")
# Each valid_time is present in the map element's data attribute.
Enum.each(times, fn t ->
iso = DateTime.to_iso8601(t)
assert html =~ iso
end)
end
test "select_time replaces the rendered weather data with the new hour's grid", %{conn: conn} do
base =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
now_t = base
future_t = DateTime.add(base, 3 * 3600, :second)
# Seed f00 with a 22°C surface and f03 with a 10°C surface so we
# can tell them apart in the push_event payload.
write_profile(now_t, 33.0, -97.0)
grid_data = %{
{33.0, -97.0} => %{
surface_temp_c: 10.0,
surface_dewpoint_c: 5.0,
surface_pressure_mb: 1010.0,
hpbl_m: 500.0,
pwat_mm: 20.0,
profile: []
}
}
ProfilesFile.write!(future_t, grid_data)
{:ok, lv, _html} = live(conn, ~p"/weather")
# Select the future hour.
render_hook(lv, "select_time", %{"time" => DateTime.to_iso8601(future_t)})
assert_push_event(lv, "update_weather", %{data: data})
assert Enum.any?(data, fn r -> r.temperature == 10.0 end)
end
end
end