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.
This commit is contained in:
parent
88866cdbeb
commit
ac3a2517d9
10 changed files with 825 additions and 3 deletions
2
algo.md
2
algo.md
|
|
@ -1,4 +1,4 @@
|
||||||
# Microwave Propagation Algorithm — Unified
|
# Microwave Propagation Algorithm
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ interface WeatherMapHook extends ViewHook {
|
||||||
map: L.Map
|
map: L.Map
|
||||||
weatherOverlay: WeatherCanvasOverlay | null
|
weatherOverlay: WeatherCanvasOverlay | null
|
||||||
weatherCellsUrl: string
|
weatherCellsUrl: string
|
||||||
|
source: string | null
|
||||||
selectedLayer: LayerId
|
selectedLayer: LayerId
|
||||||
pack: CellPack | null
|
pack: CellPack | null
|
||||||
packKey: string | null
|
packKey: string | null
|
||||||
|
|
@ -678,6 +679,7 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
|
|
||||||
this.weatherOverlay = null
|
this.weatherOverlay = null
|
||||||
this.weatherCellsUrl = "/weather/cells"
|
this.weatherCellsUrl = "/weather/cells"
|
||||||
|
this.source = this.el.dataset.source || null
|
||||||
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
|
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
|
||||||
this.pack = null
|
this.pack = null
|
||||||
this.packKey = null
|
this.packKey = null
|
||||||
|
|
@ -990,10 +992,12 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
if (cacheKey !== this.packKey) return
|
if (cacheKey !== this.packKey) return
|
||||||
|
|
||||||
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
||||||
|
const sourceQuery = this.source ? `&source=${encodeURIComponent(this.source)}` : ""
|
||||||
const url =
|
const url =
|
||||||
`${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` +
|
`${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` +
|
||||||
`&south=${south}&north=${north}&west=${west}&east=${east}` +
|
`&south=${south}&north=${north}&west=${west}&east=${east}` +
|
||||||
`&layers=${layers.join(",")}`
|
`&layers=${layers.join(",")}` +
|
||||||
|
sourceQuery
|
||||||
|
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
if (layers.length === 1 || !this.pack) this.inflightCellsAbort = abort
|
if (layers.length === 1 || !this.pack) this.inflightCellsAbort = abort
|
||||||
|
|
|
||||||
|
|
@ -917,6 +917,28 @@ defmodule Microwaveprop.Weather do
|
||||||
|> Enum.sort(DateTime)
|
|> Enum.sort(DateTime)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
All persisted HRDPS valid_times (i.e. those with a `<iso>.hrdps`
|
||||||
|
scalar dir on disk) sorted ascending. Backs the `/weather-ca`
|
||||||
|
timeline.
|
||||||
|
"""
|
||||||
|
@spec available_hrdps_valid_times() :: [DateTime.t()]
|
||||||
|
def available_hrdps_valid_times do
|
||||||
|
ScalarFile.list_valid_times_hrdps()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
HRDPS-only counterpart to `weather_grid_at/2`. Reads from the
|
||||||
|
`<vt>.hrdps` scalar dir and skips HRRR completely. Bypasses GridCache
|
||||||
|
because the cache mixes HRRR + HRDPS rows by design — caching this
|
||||||
|
separately would double the per-pod memory budget for marginal benefit
|
||||||
|
(Canadian viewport reads are infrequent compared to CONUS).
|
||||||
|
"""
|
||||||
|
@spec weather_grid_hrdps_at(DateTime.t(), %{optional(String.t()) => float()} | nil) :: [map()]
|
||||||
|
def weather_grid_hrdps_at(%DateTime{} = valid_time, bounds) do
|
||||||
|
ScalarFile.read_bounds_hrdps(valid_time, bounds)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Read the weather grid for a specific `valid_time` and bounds. Like
|
Read the weather grid for a specific `valid_time` and bounds. Like
|
||||||
`load_weather_grid/1` but takes the valid_time explicitly so the
|
`load_weather_grid/1` but takes the valid_time explicitly so the
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,18 @@ defmodule Microwaveprop.Weather.ScalarFile do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Read only the HRDPS sibling chunks for `valid_time` whose lat/lon falls
|
||||||
|
within `bounds`. Used by the `/weather-ca` endpoint, which intentionally
|
||||||
|
excludes HRRR-derived rows so the user can see Canadian-source data
|
||||||
|
without being eclipsed by the higher-resolution HRRR scalars over the
|
||||||
|
US/Canada border.
|
||||||
|
"""
|
||||||
|
@spec read_bounds_hrdps(DateTime.t(), bounds() | nil) :: [row()]
|
||||||
|
def read_bounds_hrdps(%DateTime{} = valid_time, bounds) do
|
||||||
|
read_chunks(list_chunk_files_hrdps(valid_time), bounds)
|
||||||
|
end
|
||||||
|
|
||||||
defp read_chunks([], _bounds), do: []
|
defp read_chunks([], _bounds), do: []
|
||||||
|
|
||||||
defp read_chunks(files, bounds) do
|
defp read_chunks(files, bounds) do
|
||||||
|
|
@ -263,6 +275,30 @@ defmodule Microwaveprop.Weather.ScalarFile do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Like `list_valid_times/0` but returns only the valid_times that have a
|
||||||
|
`<iso>.hrdps` directory present. Backs the `/weather-ca` timeline so
|
||||||
|
the scrubber surfaces HRDPS-only hours that wouldn't otherwise appear
|
||||||
|
on the standard `/weather` timeline.
|
||||||
|
"""
|
||||||
|
@spec list_valid_times_hrdps() :: [DateTime.t()]
|
||||||
|
def list_valid_times_hrdps do
|
||||||
|
case File.ls(base_dir()) do
|
||||||
|
{:ok, entries} ->
|
||||||
|
times =
|
||||||
|
for entry <- entries,
|
||||||
|
String.ends_with?(entry, ".hrdps"),
|
||||||
|
iso = String.trim_trailing(entry, ".hrdps"),
|
||||||
|
{:ok, dt, _} <- [DateTime.from_iso8601(iso)],
|
||||||
|
do: dt
|
||||||
|
|
||||||
|
Enum.sort(times, DateTime)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc "Delete scalar dirs whose valid_time is strictly before `cutoff`. Returns count removed."
|
@doc "Delete scalar dirs whose valid_time is strictly before `cutoff`. Returns count removed."
|
||||||
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
||||||
def prune_older_than(%DateTime{} = cutoff) do
|
def prune_older_than(%DateTime{} = cutoff) do
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
|
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
|
||||||
{:ok, bounds} <- parse_bounds(params),
|
{:ok, bounds} <- parse_bounds(params),
|
||||||
{:ok, layers} <- parse_layers(params["layers"]) do
|
{:ok, layers} <- parse_layers(params["layers"]) do
|
||||||
rows = Weather.weather_grid_at(valid_time, bounds)
|
rows = read_rows(valid_time, bounds, params["source"])
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_resp_header("content-type", "application/octet-stream")
|
|> put_resp_header("content-type", "application/octet-stream")
|
||||||
|
|
@ -31,6 +31,9 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
|
|
||||||
def cells(conn, _params), do: bad_request(conn)
|
def cells(conn, _params), do: bad_request(conn)
|
||||||
|
|
||||||
|
defp read_rows(valid_time, bounds, "hrdps"), do: Weather.weather_grid_hrdps_at(valid_time, bounds)
|
||||||
|
defp read_rows(valid_time, bounds, _other), do: Weather.weather_grid_at(valid_time, bounds)
|
||||||
|
|
||||||
defp bad_request(conn) do
|
defp bad_request(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_status(400)
|
|> put_status(400)
|
||||||
|
|
|
||||||
600
lib/microwaveprop_web/live/weather_ca_map_live.ex
Normal file
600
lib/microwaveprop_web/live/weather_ca_map_live.ex
Normal file
|
|
@ -0,0 +1,600 @@
|
||||||
|
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)} · {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
|
||||||
|
|
@ -188,6 +188,7 @@ defmodule MicrowavepropWeb.Router do
|
||||||
live "/imports/:id", ImportLive
|
live "/imports/:id", ImportLive
|
||||||
live "/map", MapLive
|
live "/map", MapLive
|
||||||
live "/weather", WeatherMapLive
|
live "/weather", WeatherMapLive
|
||||||
|
live "/weather-ca", WeatherCaMapLive
|
||||||
live "/contacts", ContactLive.Index
|
live "/contacts", ContactLive.Index
|
||||||
live "/contacts/map", ContactMapLive
|
live "/contacts/map", ContactMapLive
|
||||||
live "/contacts/:id", ContactLive.Show
|
live "/contacts/:id", ContactLive.Show
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,50 @@ defmodule Microwaveprop.Weather.ScalarFileTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "HRDPS-only reads (for the /weather-ca endpoint)" do
|
||||||
|
test "read_bounds_hrdps returns only HRDPS chunks, ignoring co-located HRRR" do
|
||||||
|
vt = ~U[2026-04-29 12:00:00Z]
|
||||||
|
|
||||||
|
# Both dirs cover the same point. The HRRR-only read_bounds/2 would
|
||||||
|
# merge them (preferring HRRR); read_bounds_hrdps/2 must return ONLY
|
||||||
|
# the HRDPS row so /weather-ca shows Canadian-source data.
|
||||||
|
ScalarFile.write!(vt, [sample_row(33.0, -97.0, 22.0)])
|
||||||
|
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
|
||||||
|
|
||||||
|
assert [%{lat: 53.0, lon: -60.0, temperature: 8.0}] =
|
||||||
|
ScalarFile.read_bounds_hrdps(vt, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_bounds_hrdps respects bounds" do
|
||||||
|
vt = ~U[2026-04-29 12:00:00Z]
|
||||||
|
|
||||||
|
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
|
||||||
|
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 33.0, -97.0, 22.0)
|
||||||
|
|
||||||
|
bounds = %{"south" => 50.0, "north" => 60.0, "west" => -70.0, "east" => -50.0}
|
||||||
|
assert [%{lat: 53.0, lon: -60.0}] = ScalarFile.read_bounds_hrdps(vt, bounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_bounds_hrdps returns [] when no HRDPS dir exists" do
|
||||||
|
vt = ~U[2026-04-29 12:00:00Z]
|
||||||
|
ScalarFile.write!(vt, [sample_row(33.0, -97.0, 22.0)])
|
||||||
|
|
||||||
|
assert ScalarFile.read_bounds_hrdps(vt, nil) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "list_valid_times_hrdps returns times from HRDPS-suffixed dirs only" do
|
||||||
|
vt1 = ~U[2026-04-29 12:00:00Z]
|
||||||
|
vt2 = ~U[2026-04-29 13:00:00Z]
|
||||||
|
vt_hrrr_only = ~U[2026-04-29 14:00:00Z]
|
||||||
|
|
||||||
|
hand_write_chunk(ScalarFile.dir_for_hrdps(vt1), 53.0, -60.0, 8.0)
|
||||||
|
hand_write_chunk(ScalarFile.dir_for_hrdps(vt2), 53.0, -60.0, 9.0)
|
||||||
|
ScalarFile.write!(vt_hrrr_only, [sample_row(33.0, -97.0, 22.0)])
|
||||||
|
|
||||||
|
assert ScalarFile.list_valid_times_hrdps() == [vt1, vt2]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp hand_write_chunk(dir, lat, lon, temp) do
|
defp hand_write_chunk(dir, lat, lon, temp) do
|
||||||
File.mkdir_p!(dir)
|
File.mkdir_p!(dir)
|
||||||
lat_band = (lat / 5) |> Float.floor() |> trunc()
|
lat_band = (lat / 5) |> Float.floor() |> trunc()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
||||||
|
|
||||||
alias Microwaveprop.Propagation.ProfilesFile
|
alias Microwaveprop.Propagation.ProfilesFile
|
||||||
alias Microwaveprop.Weather.GridCache
|
alias Microwaveprop.Weather.GridCache
|
||||||
|
alias Microwaveprop.Weather.ScalarFile
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
GridCache.clear()
|
GridCache.clear()
|
||||||
|
|
@ -110,5 +111,59 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
||||||
body = response(conn, 200)
|
body = response(conn, 200)
|
||||||
assert <<"WCEL", 1::8, 0::little-32, _layer_count::8, _rest::binary>> = body
|
assert <<"WCEL", 1::8, 0::little-32, _layer_count::8, _rest::binary>> = body
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "with ?source=hrdps returns only HRDPS rows (skips HRRR scalars and ProfilesFile)", %{conn: conn} do
|
||||||
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
||||||
|
|
||||||
|
# HRRR side: write a Texas row via the standard pipeline.
|
||||||
|
ProfilesFile.write!(valid_time, %{
|
||||||
|
{33.0, -97.0} => %{
|
||||||
|
surface_temp_c: 22.0,
|
||||||
|
surface_dewpoint_c: 12.0,
|
||||||
|
surface_pressure_mb: 1010.0,
|
||||||
|
hpbl_m: 800.0,
|
||||||
|
pwat_mm: 25.0,
|
||||||
|
profile: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# HRDPS side: hand-write a Canadian chunk into the HRDPS sibling dir.
|
||||||
|
hrdps_dir = ScalarFile.dir_for_hrdps(valid_time)
|
||||||
|
File.mkdir_p!(hrdps_dir)
|
||||||
|
lat_band = (53.0 / 5) |> Float.floor() |> trunc()
|
||||||
|
lon_band = (-60.0 / 5) |> Float.floor() |> trunc()
|
||||||
|
path = Path.join(hrdps_dir, "#{lat_band}_#{lon_band}.mp.gz")
|
||||||
|
|
||||||
|
payload =
|
||||||
|
Msgpax.pack!(
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
"lat" => 53.0,
|
||||||
|
"lon" => -60.0,
|
||||||
|
"valid_time" => "2026-04-28T12:00:00Z",
|
||||||
|
"temperature" => 8.0,
|
||||||
|
"surface_rh" => 70.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
iodata: false
|
||||||
|
)
|
||||||
|
|
||||||
|
File.write!(path, :zlib.gzip(payload))
|
||||||
|
|
||||||
|
time = DateTime.to_iso8601(valid_time)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
~p"/weather/cells?time=#{time}&south=40.0&north=70.0&west=-150.0&east=-50.0&source=hrdps"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = response(conn, 200)
|
||||||
|
assert <<"WCEL", 1::8, cell_count::little-32, _rest::binary>> = body
|
||||||
|
# Only the Canadian (53, -60) row — HRRR's (33, -97) is excluded.
|
||||||
|
assert cell_count == 1
|
||||||
|
|
||||||
|
File.rm_rf!(hrdps_dir)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
57
test/microwaveprop_web/live/weather_ca_map_live_test.exs
Normal file
57
test/microwaveprop_web/live/weather_ca_map_live_test.exs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
defmodule MicrowavepropWeb.WeatherCaMapLiveTest do
|
||||||
|
# async: false because we touch the shared scalar / profile dirs.
|
||||||
|
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()
|
||||||
|
ProfilesFile.prune_older_than(~U[2099-12-31 23:59:59Z])
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "mounts at /weather-ca with Canadian default viewport", %{conn: conn} do
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/weather-ca")
|
||||||
|
|
||||||
|
# Default viewport is centered roughly on central Canada at a low
|
||||||
|
# zoom — different from /weather's DFW@z7. The exact numbers are
|
||||||
|
# asserted so a routing accident (mounting WeatherMapLive at this
|
||||||
|
# path by mistake) is caught.
|
||||||
|
assert html =~ ~s(data-initial-lat="55.0")
|
||||||
|
assert html =~ ~s(data-initial-lon="-100.0")
|
||||||
|
assert html =~ ~s(data-initial-zoom="4")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders the weather map element with data-source=hrdps", %{conn: conn} do
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/weather-ca")
|
||||||
|
|
||||||
|
# The JS hook reads data-source and appends &source=hrdps to its cell
|
||||||
|
# fetches. Without this attribute the page would silently fall back
|
||||||
|
# to HRRR-only reads and never show Canadian data.
|
||||||
|
assert html =~ ~s(data-source="hrdps")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewport_changed event push_patches /weather-ca, not /weather", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/weather-ca")
|
||||||
|
|
||||||
|
render_hook(lv, "viewport_changed", %{"lat" => 60.0, "lon" => -110.0, "zoom" => 5})
|
||||||
|
|
||||||
|
assert_patch(lv, "/weather-ca?layer=temperature&lat=60.0&lon=-110.0&zoom=5")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "select_layer push_patches a /weather-ca URL", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/weather-ca")
|
||||||
|
|
||||||
|
render_hook(lv, "select_layer", %{"layer" => "pwat"})
|
||||||
|
|
||||||
|
assert_patch(lv, "/weather-ca?layer=pwat&lat=55.0&lon=-100.0&zoom=4")
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue