From ac3a2517d99dbedd799d77f19e763aae9e681562 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 30 Apr 2026 12:08:48 -0500 Subject: [PATCH] feat(weather): /weather-ca endpoint shows HRDPS-only Canadian data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .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. --- algo.md | 2 +- assets/js/weather_map_hook.ts | 6 +- lib/microwaveprop/weather.ex | 22 + lib/microwaveprop/weather/scalar_file.ex | 36 ++ .../controllers/weather_tile_controller.ex | 5 +- .../live/weather_ca_map_live.ex | 600 ++++++++++++++++++ lib/microwaveprop_web/router.ex | 1 + .../weather/scalar_file_test.exs | 44 ++ .../weather_tile_controller_test.exs | 55 ++ .../live/weather_ca_map_live_test.exs | 57 ++ 10 files changed, 825 insertions(+), 3 deletions(-) create mode 100644 lib/microwaveprop_web/live/weather_ca_map_live.ex create mode 100644 test/microwaveprop_web/live/weather_ca_map_live_test.exs diff --git a/algo.md b/algo.md index c97a93d1..f73a1b20 100644 --- a/algo.md +++ b/algo.md @@ -1,4 +1,4 @@ -# Microwave Propagation Algorithm — Unified +# Microwave Propagation Algorithm ## Overview diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 42716e74..e8f46e97 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -81,6 +81,7 @@ interface WeatherMapHook extends ViewHook { map: L.Map weatherOverlay: WeatherCanvasOverlay | null weatherCellsUrl: string + source: string | null selectedLayer: LayerId pack: CellPack | null packKey: string | null @@ -678,6 +679,7 @@ export const WeatherMap: WeatherMapHook = { this.weatherOverlay = null this.weatherCellsUrl = "/weather/cells" + this.source = this.el.dataset.source || null this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId this.pack = null this.packKey = null @@ -990,10 +992,12 @@ export const WeatherMap: WeatherMapHook = { if (cacheKey !== this.packKey) return const { south, north, west, east } = quantizeBounds(this.map.getBounds()) + const sourceQuery = this.source ? `&source=${encodeURIComponent(this.source)}` : "" const url = `${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` + `&south=${south}&north=${north}&west=${west}&east=${east}` + - `&layers=${layers.join(",")}` + `&layers=${layers.join(",")}` + + sourceQuery const abort = new AbortController() if (layers.length === 1 || !this.pack) this.inflightCellsAbort = abort diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index a1894339..55a059c1 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -917,6 +917,28 @@ defmodule Microwaveprop.Weather do |> Enum.sort(DateTime) end + @doc """ + All persisted HRDPS valid_times (i.e. those with a `.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 + `.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 """ Read the weather grid for a specific `valid_time` and bounds. Like `load_weather_grid/1` but takes the valid_time explicitly so the diff --git a/lib/microwaveprop/weather/scalar_file.ex b/lib/microwaveprop/weather/scalar_file.ex index 1c48a075..50ac4f67 100644 --- a/lib/microwaveprop/weather/scalar_file.ex +++ b/lib/microwaveprop/weather/scalar_file.ex @@ -179,6 +179,18 @@ defmodule Microwaveprop.Weather.ScalarFile do 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(files, bounds) do @@ -263,6 +275,30 @@ defmodule Microwaveprop.Weather.ScalarFile do end end + @doc """ + Like `list_valid_times/0` but returns only the valid_times that have a + `.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." @spec prune_older_than(DateTime.t()) :: non_neg_integer() def prune_older_than(%DateTime{} = cutoff) do diff --git a/lib/microwaveprop_web/controllers/weather_tile_controller.ex b/lib/microwaveprop_web/controllers/weather_tile_controller.ex index 0552c35c..2966329d 100644 --- a/lib/microwaveprop_web/controllers/weather_tile_controller.ex +++ b/lib/microwaveprop_web/controllers/weather_tile_controller.ex @@ -18,7 +18,7 @@ defmodule MicrowavepropWeb.WeatherTileController do with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param), {:ok, bounds} <- parse_bounds(params), {:ok, layers} <- parse_layers(params["layers"]) do - rows = Weather.weather_grid_at(valid_time, bounds) + rows = read_rows(valid_time, bounds, params["source"]) conn |> put_resp_header("content-type", "application/octet-stream") @@ -31,6 +31,9 @@ defmodule MicrowavepropWeb.WeatherTileController do 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 conn |> put_status(400) diff --git a/lib/microwaveprop_web/live/weather_ca_map_live.ex b/lib/microwaveprop_web/live/weather_ca_map_live.ex new file mode 100644 index 00000000..9b7fb85b --- /dev/null +++ b/lib/microwaveprop_web/live/weather_ca_map_live.ex @@ -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 + `.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""" +
+
+
+
+ +
+
+ +
+
+
+
+ NTMS +
Canadian Weather Map
+
+ <%= if @valid_time do %> + {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")} + <% else %> + No data available + <% end %> +
+
+
+
+ {@initial_utc_clock} +
+ +
+
+ + +
+
+ + + + +
+ + +
+ + + """ + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 399aa726..2a9a1f81 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -188,6 +188,7 @@ defmodule MicrowavepropWeb.Router do live "/imports/:id", ImportLive live "/map", MapLive live "/weather", WeatherMapLive + live "/weather-ca", WeatherCaMapLive live "/contacts", ContactLive.Index live "/contacts/map", ContactMapLive live "/contacts/:id", ContactLive.Show diff --git a/test/microwaveprop/weather/scalar_file_test.exs b/test/microwaveprop/weather/scalar_file_test.exs index e046e052..ecf62724 100644 --- a/test/microwaveprop/weather/scalar_file_test.exs +++ b/test/microwaveprop/weather/scalar_file_test.exs @@ -202,6 +202,50 @@ defmodule Microwaveprop.Weather.ScalarFileTest do 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 File.mkdir_p!(dir) lat_band = (lat / 5) |> Float.floor() |> trunc() diff --git a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs index 4038fb85..557d03ea 100644 --- a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs +++ b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs @@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do alias Microwaveprop.Propagation.ProfilesFile alias Microwaveprop.Weather.GridCache + alias Microwaveprop.Weather.ScalarFile setup do GridCache.clear() @@ -110,5 +111,59 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do body = response(conn, 200) assert <<"WCEL", 1::8, 0::little-32, _layer_count::8, _rest::binary>> = body 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 diff --git a/test/microwaveprop_web/live/weather_ca_map_live_test.exs b/test/microwaveprop_web/live/weather_ca_map_live_test.exs new file mode 100644 index 00000000..1a953bf0 --- /dev/null +++ b/test/microwaveprop_web/live/weather_ca_map_live_test.exs @@ -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