prop/lib/microwaveprop_web/live/weather_ca_map_live.ex
Graham McIntire ac3a2517d9
feat(weather): /weather-ca endpoint shows HRDPS-only Canadian data
New LiveView at /weather-ca duplicates the /weather UI but defaults the
viewport to central Canada (55N, -100W, z=4) and renders the map div
with data-source="hrdps". The JS hook reads that attribute and appends
&source=hrdps to every cell fetch; the WeatherTileController routes
those reads through Weather.weather_grid_hrdps_at/2, which only reads
the <vt>.hrdps scalar dir and skips HRRR merging entirely. Timeline is
sourced from ScalarFile.list_valid_times_hrdps/0 so HRDPS-only hours
show up even when no HRRR file exists for the same valid_time.

Also drops "— Unified" from the algo.md H1.
2026-04-30 12:08:48 -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
defp pick_initial_valid_time([]), do: nil
defp pick_initial_valid_time(valid_times) do
now = DateTime.utc_now()
Enum.min_by(valid_times, fn vt -> abs(DateTime.diff(vt, now, :second)) end)
end
# Drop valid_times older than one hour. HRDPS cycles run 4×/day and
# the chain seeder writes one row per hour, so the on-disk set can
# span many old hours; the user wants "now", not yesterday.
defp recent_valid_times(valid_times) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, cutoff) != :lt end)
end
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