263 lines
8.2 KiB
Elixir
263 lines
8.2 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
|
||
alias MicrowavepropWeb.WeatherMapComponent
|
||
|
||
@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,
|
||
band_filter: nil,
|
||
cutoff_bands: []
|
||
)}
|
||
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
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
assigns =
|
||
assign(assigns,
|
||
page_subtitle: "Canadian Weather Map",
|
||
data_source: "hrdps",
|
||
show_cutoff_bands: false,
|
||
show_other_weather_link: true,
|
||
other_weather_path: "/weather",
|
||
other_weather_label: "US Weather Map"
|
||
)
|
||
|
||
WeatherMapComponent.render(assigns)
|
||
end
|
||
end
|