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:
parent
cacce902d3
commit
725a7e6bda
6 changed files with 417 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)} · {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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
114
test/microwaveprop_web/live/weather_map_live_test.exs
Normal file
114
test/microwaveprop_web/live/weather_map_live_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue