prop/lib/microwaveprop_web/live/weather_ca_map_live.ex
Graham McIntire b37692592b
fix(weather): default selected time to current hour, not clock-closest
Past minute 30 of the wall clock the next forecast hour is closer to
utc_now() than the current hour, so the picker would land on (e.g.)
19:00Z when the user is at 18:43Z. HRRR has 19:00Z, but the HRDPS
chain only seeds the *current* hour (18:00Z), so the dual-source map
showed an empty Canadian half whenever we crossed the half-hour mark.

Switch to "most-recent valid_time <= now", with a fallback to the
closest future when nothing on disk is yet at-or-before now (cold
start). Hoist the picker into a public function so it can be unit-
tested deterministically — anchoring the integration test on
DateTime.utc_now() truncated to the hour would have only verified the
new behavior past minute 30.

/weather-ca delegates to the same picker so the two maps agree on
"current."
2026-04-30 13:50:33 -05:00

600 lines
21 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.WeatherCaMapLive do
@moduledoc """
`/weather-ca` — sibling of `/weather` that shows ONLY HRDPS-derived
Canadian fields. Same UI, same JS hook, same layer set — but the cell
fetch is filtered to `source=hrdps` and the timeline is sourced from
`<vt>.hrdps` scalar dirs only. Defaults the viewport to central Canada.
"""
use MicrowavepropWeb, :live_view
alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers
@layers MapLayers.all()
@default_layer MapLayers.default_id()
# Default map viewport — central Canada at z=4 so the user lands on
# the HRDPS coverage area, not DFW.
@default_center_lat 55.0
@default_center_lon -100.0
@default_zoom 4
@min_zoom 4
@max_zoom 10
@impl true
def mount(params, _session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
end
valid_times = recent_valid_times(Weather.available_hrdps_valid_times())
initial_vt = pick_initial_valid_time(valid_times)
{lat, lon, zoom} = parse_viewport_params(params)
{:ok,
assign(socket,
page_title: "Canadian Weather Map",
layers: @layers,
selected_layer: @default_layer,
valid_time: initial_vt,
valid_times: valid_times,
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
initial_selected_time: initial_vt && DateTime.to_iso8601(initial_vt),
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
initial_center_lat: lat,
initial_center_lon: lon,
initial_zoom: zoom,
current_lat: lat,
current_lon: lon,
current_zoom: zoom,
grid_visible: false,
radar_visible: false
)}
end
@impl true
def handle_params(params, _uri, socket) do
selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
{lat, lon, zoom} = parse_viewport_params(params)
{:noreply,
socket
|> assign(:selected_layer, selected)
|> assign(:current_lat, lat)
|> assign(:current_lon, lon)
|> assign(:current_zoom, zoom)}
end
defp pick_layer(layer, current) when is_binary(layer) do
if MapLayers.valid_id?(layer), do: layer, else: pick_layer(nil, current)
end
defp pick_layer(_layer, current) when is_binary(current), do: current
defp pick_layer(_layer, _current), do: @default_layer
defp parse_viewport_params(params) do
lat = parse_float_param(params["lat"], -90.0, 90.0, @default_center_lat)
lon = parse_float_param(params["lon"], -180.0, 180.0, @default_center_lon)
zoom =
case Integer.parse(params["zoom"] || "") do
{z, ""} when z >= @min_zoom and z <= @max_zoom -> z
_ -> @default_zoom
end
{lat, lon, zoom}
end
defp parse_float_param(value, min, max, default) when is_binary(value) do
case Float.parse(value) do
{f, ""} when f >= min and f <= max -> f
_ -> default
end
end
defp parse_float_param(_value, _min, _max, default), do: default
# Delegate to WeatherMapLive's picker so the two maps agree on what
# "current" means: most-recent <=now, with a fall-through to the
# closest future when nothing is yet at-or-before now.
defp pick_initial_valid_time(valid_times) do
MicrowavepropWeb.WeatherMapLive.pick_initial_valid_time(valid_times)
end
# Don't filter by recency. HRDPS publishes 4×/day with a 3-4h
# publication latency and our chain step adds ~13 min on top, so the
# freshest valid_time on disk is routinely 4-6h old. /weather's 1h
# cutoff would leave /weather-ca empty most of the time. The sidebar
# shows the selected valid_time in big letters — users can see for
# themselves how stale the data is.
defp recent_valid_times(valid_times), do: valid_times
defp build_path(socket, overrides \\ []) do
layer = Keyword.get(overrides, :layer, socket.assigns.selected_layer)
lat = Keyword.get(overrides, :lat, socket.assigns.current_lat)
lon = Keyword.get(overrides, :lon, socket.assigns.current_lon)
zoom = Keyword.get(overrides, :zoom, socket.assigns.current_zoom)
query =
URI.encode_query([
{"layer", layer},
{"lat", lat},
{"lon", lon},
{"zoom", zoom}
])
"/weather-ca?#{query}"
end
@impl true
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
if MapLayers.valid_id?(layer_id) do
{:noreply, push_patch(socket, to: build_path(socket, layer: layer_id))}
else
{:noreply, socket}
end
end
def handle_event("viewport_changed", %{"lat" => lat, "lon" => lon, "zoom" => zoom}, socket)
when is_number(lat) and is_number(lon) and is_number(zoom) do
new_lat = lat |> max(-90.0) |> min(90.0) |> Float.round(4)
new_lon = lon |> max(-180.0) |> min(180.0) |> Float.round(4)
new_zoom = zoom |> max(@min_zoom) |> min(@max_zoom) |> trunc()
socket =
socket
|> assign(:current_lat, new_lat)
|> assign(:current_lon, new_lon)
|> assign(:current_zoom, new_zoom)
{:noreply, push_patch(socket, to: build_path(socket), replace: true)}
end
def handle_event("viewport_changed", _params, socket), do: {:noreply, socket}
def handle_event("toggle_grid", _params, socket) do
visible = !socket.assigns.grid_visible
socket =
socket
|> assign(:grid_visible, visible)
|> push_event("toggle_grid", %{visible: visible})
{:noreply, socket}
end
def handle_event("toggle_radar", _params, socket) do
visible = !socket.assigns.radar_visible
socket =
socket
|> assign(:radar_visible, visible)
|> push_event("toggle_radar", %{visible: visible})
{: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
socket =
socket
|> assign(:valid_time, time)
|> push_event("update_weather_overlay", %{selected: DateTime.to_iso8601(time)})
{: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
Weather.weather_point_detail(lat, lon, socket.assigns.valid_time)
end
payload = detail || %{}
{:noreply, push_event(socket, "point_detail", payload)}
end
@impl true
def handle_info({:weather_updated, _valid_time}, socket) do
valid_times = recent_valid_times(Weather.available_hrdps_valid_times())
selected =
cond do
socket.assigns.valid_time in valid_times -> socket.assigns.valid_time
valid_times != [] -> List.last(valid_times)
true -> nil
end
socket =
socket
|> assign(:valid_times, valid_times)
|> assign(:valid_time, selected)
|> push_event("update_weather_overlay", %{selected: selected && DateTime.to_iso8601(selected)})
|> push_event("update_timeline", %{
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
selected: selected && DateTime.to_iso8601(selected)
})
{:noreply, socket}
end
def handle_info({:propagation_pipeline_progress, _progress}, socket) do
valid_times = recent_valid_times(Weather.available_hrdps_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
layers
|> Enum.group_by(& &1.group)
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
end
defp layer_description(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{desc: desc} -> desc
_ -> nil
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"""
<div class="flex w-screen h-screen overflow-hidden">
<div class="relative flex-1 min-w-0">
<div
id="weather-map"
phx-hook="WeatherMap"
phx-update="ignore"
data-layers={Jason.encode!(@layers)}
data-selected-layer={@selected_layer}
data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time}
data-initial-lat={@initial_center_lat}
data-initial-lon={@initial_center_lon}
data-initial-zoom={@initial_zoom}
data-source="hrdps"
class="absolute inset-0 z-0"
>
</div>
<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>
<div
id="weather-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Canadian Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-mobile"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div id="weather-panel-extras" class="hidden flex-col gap-2">
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="text-[11px] opacity-70 px-1 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
<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">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
US Weather Map
</.link>
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
</.link>
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
EME
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<div
id="weather-detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<button
id="weather-sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<div
id="weather-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Canadian Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="px-1">
<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-[11px] opacity-70 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<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">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/weather">US Weather Map</.link>
</li>
<li>
<.link navigate="/map">Propagation Map</.link>
</li>
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/eme">EME</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li>
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
{@current_scope.user.callsign}
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end