The 1-hour valid_time filter inherited from /weather is wrong for HRDPS: the model publishes 4x/day with 3-4h publication latency and the chain step adds ~13 min on top, so the freshest data on disk is routinely 4-6h old. With the 1h cutoff in place, /weather-ca's timeline was empty whenever there wasn't an active chain step running, which left the JS hook with nothing to fetch. The sidebar already shows the selected valid_time prominently — users can see for themselves how stale the data is and don't need the page to hide it.
600 lines
21 KiB
Elixir
600 lines
21 KiB
Elixir
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
|
||
|
||
# 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)} · {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
|