From e3d430f8c4716464b7a3ac7f17114120c2bc0bed Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 15 May 2026 19:51:24 -0500 Subject: [PATCH] feat(weather): add duct cutoff band map layer Show on /weather where the detected duct geometry traps each microwave band. Overview mode bins cells by their lowest trapped frequency (Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't reach the selected band. Cutoff is pre-computed per cell in both writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from CellValues), persisted to ScalarFile, and shipped to the JS hook via the existing binary cell pack. Also cleans up six pre-existing length/1 credo warnings in unrelated test files. --- assets/js/weather_map_hook.ts | 135 +++++++++++++++++- .../2026-05-15-duct-cutoff-weather-layer.md | 99 +++++++++++++ lib/microwaveprop/weather/map_layers.ex | 8 ++ lib/microwaveprop/weather/scalar_file.ex | 3 +- lib/microwaveprop/weather/weather_layers.ex | 42 +++++- .../controllers/weather_tile_controller.ex | 2 +- .../live/weather_map_live.ex | 111 +++++++++++++- rust/prop_grid_rs/src/weather_scalar_file.rs | 49 ++++++- test/microwaveprop/buildings/loader_test.exs | 2 +- .../buildings/ms_footprints_test.exs | 2 +- .../microwaveprop/weather/map_layers_test.exs | 2 +- .../weather/untested_functions_test.exs | 2 +- .../weather/weather_layers_test.exs | 52 +++++++ .../api/v1/profile_controller_test.exs | 4 +- 14 files changed, 487 insertions(+), 26 deletions(-) create mode 100644 docs/plans/2026-05-15-duct-cutoff-weather-layer.md diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 993c3a6a..c1268d77 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -30,7 +30,18 @@ interface BooleanColorScale { unit: string } -type ColorScale = ContinuousColorScale | BooleanColorScale +// Discrete band-step scale: each step is (upper-bound GHz, RGB). The +// first step whose `upTo` >= value wins. Used by the duct-cutoff +// layer so cells snap to amateur-band bins instead of interpolating. +interface BandStepsColorScale { + breakpoints: "band_steps" + steps: Array<{ upTo: number } & RGB> + format: (v: number | null) => string + label: string + unit: string +} + +type ColorScale = ContinuousColorScale | BooleanColorScale | BandStepsColorScale type LayerId = keyof typeof COLOR_SCALES @@ -62,6 +73,7 @@ interface WeatherPoint { ducting: boolean | null duct_base_m: number | null duct_strength: number | null + duct_cutoff_ghz: number | null [key: string]: unknown } @@ -91,6 +103,7 @@ interface WeatherMapHook extends ViewHook { // otherwise ["hrrr", "hrdps"] so the main map paints both grids. sources: SourceName[] selectedLayer: LayerId + bandFilter: number | null packs: Map packKey: string | null inflightAborts: Map @@ -141,6 +154,12 @@ function isMobile(): boolean { return window.innerWidth < 768 } +function parseBandFilter(raw: string | undefined): number | null { + if (!raw) return null + const n = Number.parseInt(raw, 10) + return Number.isFinite(n) && n > 0 ? n : null +} + const COLOR_SCALES = { refractivity_gradient: { breakpoints: [ @@ -360,6 +379,27 @@ const COLOR_SCALES = { format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A", label: "Duct Strength", unit: "M" + }, + // Lowest band the duct supports. A low cutoff means even the + // popular lower bands work; a high cutoff means the duct is so thin + // only millimeter-wave gets trapped. Greener = broader operational + // utility. + duct_cutoff_ghz: { + breakpoints: "band_steps", + steps: [ + { upTo: 3.4, r: 0, g: 200, b: 130 }, + { upTo: 5.7, r: 0, g: 255, b: 163 }, + { upTo: 10, r: 125, g: 255, b: 212 }, + { upTo: 24, r: 255, g: 229, b: 102 }, + { upTo: 47, r: 255, g: 180, b: 80 }, + { upTo: 76, r: 255, g: 144, b: 68 }, + { upTo: 122, r: 255, g: 100, b: 80 }, + { upTo: 241, r: 255, g: 79, b: 79 }, + { upTo: Infinity, r: 200, g: 50, b: 120 } + ], + format: (v: number | null): string => v != null ? `≥ ${v.toFixed(1)} GHz` : "No duct", + label: "Duct Cutoff Band", + unit: "GHz" } } as const satisfies Record @@ -477,13 +517,31 @@ interface BoolPaintScale { trueColor: string } -function buildPaintScale(layerId: LayerId): PaintScale | BoolPaintScale | null { +// Discrete steps with an optional ≤ threshold filter ("band picker +// mode"). When `mask` is set, only paint cells whose value is ≤ mask; +// when null, paint all cells using their step color. +interface BandStepsPaintScale { + kind: "band_steps" + steps: Array<{ upTo: number; color: string }> + mask: number | null +} + +type AnyPaintScale = PaintScale | BoolPaintScale | BandStepsPaintScale + +function buildPaintScale(layerId: LayerId, bandFilter: number | null): AnyPaintScale | null { const scale = COLOR_SCALES[layerId] if (!scale) return null if (scale.breakpoints === null) { const c = scale.trueColor return { kind: "bool", trueColor: `rgb(${c.r},${c.g},${c.b})` } } + if (scale.breakpoints === "band_steps") { + return { + kind: "band_steps", + steps: scale.steps.map(s => ({ upTo: s.upTo, color: `rgb(${s.r},${s.g},${s.b})` })), + mask: bandFilter + } + } return { kind: "continuous", ...buildContinuousLut(scale.breakpoints) } } @@ -500,6 +558,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & { onRemove: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay setPack: (this: WeatherCanvasOverlay, pack: CellPack | null) => void setLayer: (this: WeatherCanvasOverlay, layerId: LayerId) => void + setBandFilter: (this: WeatherCanvasOverlay, band: number | null) => void _reset: (this: WeatherCanvasOverlay) => void _draw: (this: WeatherCanvasOverlay) => void } = { @@ -507,6 +566,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & { this.pack = pack this.layerId = layerId this.halfStep = halfStep + this.bandFilter = null }, onAdd(map: L.Map): WeatherCanvasOverlay { this._map = map @@ -534,6 +594,10 @@ const WeatherCanvasOverlayProto: L.LayerOptions & { this.layerId = layerId this._reset() }, + setBandFilter(band: number | null): void { + this.bandFilter = band + this._reset() + }, _reset(): void { if (!this._map) return const map = this._map @@ -554,7 +618,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & { const values = this.pack.layers.get(this.layerId) if (!values) return - const paintScale = buildPaintScale(this.layerId) + const paintScale = buildPaintScale(this.layerId, this.bandFilter) if (!paintScale) return const map = this._map @@ -576,6 +640,15 @@ const WeatherCanvasOverlayProto: L.LayerOptions & { if (paintScale.kind === "bool") { if (v < 0.5) continue color = paintScale.trueColor + } else if (paintScale.kind === "band_steps") { + // Band-picker mode: only paint cells whose cutoff is at or + // below the selected band — that's the operational "this band + // works here" mask. Overview mode (mask null) paints every + // cell by its step color. + if (paintScale.mask != null && v > paintScale.mask) continue + const step = paintScale.steps.find(s => v <= s.upTo) + if (!step) continue + color = step.color } else { const t = (v - paintScale.lo) / (paintScale.hi - paintScale.lo) const idx = t <= 0 ? 0 : t >= 1 ? 255 : Math.round(t * 255) @@ -600,10 +673,12 @@ interface WeatherCanvasOverlay extends L.Layer { pack: CellPack | null layerId: LayerId halfStep: number + bandFilter: number | null _map: L.Map _canvas: HTMLCanvasElement setPack(pack: CellPack | null): void setLayer(layerId: LayerId): void + setBandFilter(band: number | null): void } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -633,7 +708,8 @@ function buildDetailHTML(point: WeatherPoint): string { { separator: true }, { label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" }, { label: "Duct Base", value: COLOR_SCALES.duct_base_m.format(point.duct_base_m), color: point.duct_base_m != null ? "#00ffa3" : "#666" }, - { label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" } + { label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" }, + { label: "Duct Cutoff", value: COLOR_SCALES.duct_cutoff_ghz.format(point.duct_cutoff_ghz), color: point.duct_cutoff_ghz != null ? "#00ffa3" : "#666" } ] const mobile = isMobile() @@ -706,6 +782,7 @@ export const WeatherMap: WeatherMapHook = { // CONUS-only viewport gets an empty HRDPS pack and vice versa. this.sources = this.source === "hrdps" ? ["hrdps"] : ["hrrr", "hrdps"] this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId + this.bandFilter = parseBandFilter(this.el.dataset.bandFilter) this.packs = new Map() this.packKey = null this.inflightAborts = new Map() @@ -731,6 +808,13 @@ export const WeatherMap: WeatherMapHook = { this.fetchAndPaintCells({ force: true }) }) + this.handleEvent("set_band_filter", ({ band }: { band: number | null }) => { + this.bandFilter = (typeof band === "number" && Number.isFinite(band)) ? band : null + for (const overlay of this.weatherOverlays.values()) { + overlay.setBandFilter(this.bandFilter) + } + }) + this.detailPanel = document.getElementById("weather-detail-panel") if (this.detailPanel) { L.DomEvent.disableClickPropagation(this.detailPanel) @@ -875,16 +959,31 @@ export const WeatherMap: WeatherMapHook = { document.addEventListener("visibilitychange", this.visibilityHandler) this._layerObserver = new MutationObserver(() => { + let changed = false const newLayer = this.el.dataset.selectedLayer as LayerId | undefined if (newLayer && newLayer !== this.selectedLayer) { this.selectedLayer = newLayer localStorage.setItem("weatherMap.selectedLayer", newLayer) - // Layer switch: re-color the cells we already have. No network. + changed = true + } + const newBand = parseBandFilter(this.el.dataset.bandFilter) + if (newBand !== this.bandFilter) { + this.bandFilter = newBand + for (const overlay of this.weatherOverlays.values()) { + overlay.bandFilter = newBand + } + changed = true + } + if (changed) { + // Layer or band switch: re-color the cells we already have. No network. this.paintOverlay() this.refreshLegend() } }) - this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] }) + this._layerObserver.observe(this.el, { + attributes: true, + attributeFilter: ["data-selected-layer", "data-band-filter"] + }) // Mount the legend control once and update its content in place. // Removing + re-adding the control on every renderLayer was a @@ -1106,10 +1205,12 @@ export const WeatherMap: WeatherMapHook = { if (!overlay) { const newOverlay = new WeatherCanvasOverlayCtor(pack, this.selectedLayer, halfStepForSource(src)) + newOverlay.bandFilter = this.bandFilter newOverlay.addTo(this.map) this.weatherOverlays.set(src, newOverlay) } else { overlay.pack = pack + overlay.bandFilter = this.bandFilter overlay.setLayer(this.selectedLayer) } } @@ -1136,6 +1237,28 @@ export const WeatherMap: WeatherMapHook = { `None` } + if (scale.breakpoints === "band_steps") { + const steps = (scale as BandStepsColorScale).steps + const heading = this.bandFilter != null + ? `${scale.label} · ≤ ${this.bandFilter} GHz` + : scale.label + const rowFor = (s: typeof steps[number], i: number) => { + const prev = i > 0 ? steps[i - 1].upTo : 0 + const label = !Number.isFinite(s.upTo) + ? `> ${prev} GHz` + : prev === 0 + ? `≤ ${s.upTo} GHz` + : `≤ ${s.upTo} GHz` + const dim = this.bandFilter != null && prev > this.bandFilter ? "opacity:0.3;" : "" + return `${label}` + } + const wrapStyle = mobile + ? "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;" + : "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;" + const header = mobile ? "" : `${heading}
` + return `
${header}${steps.map(rowFor).join("
")}
` + } + const bp = (scale as ContinuousColorScale).breakpoints if (mobile) { diff --git a/docs/plans/2026-05-15-duct-cutoff-weather-layer.md b/docs/plans/2026-05-15-duct-cutoff-weather-layer.md new file mode 100644 index 00000000..eb2cf7db --- /dev/null +++ b/docs/plans/2026-05-15-duct-cutoff-weather-layer.md @@ -0,0 +1,99 @@ +# Duct Cutoff Weather Layer + +Add a `/weather` overlay that colours each grid cell by the lowest microwave-band frequency that its currently-detected duct(s) can trap. Operators can see at a glance where the duct geometry is strong enough to support 10 / 24 / 47 / 76 / 122 / 241 GHz operation. + +Physics: the Bean & Dutton waveguide approximation +`f_min = c / (2.5·d·√(ΔM·1e-6))` is already implemented in +both `Microwaveprop.Propagation.Duct.min_trapped_frequency_ghz/1` +(Elixir) and `prop_grid_rs::duct::min_trapped_frequency_ghz` (Rust). +Per-duct `min_freq_ghz` is already pre-computed at HRRR ingestion. + +## What ships + +A new map layer in the existing `/weather` picker, group `"Ducting"`, +id `"duct_cutoff_ghz"`. Two render modes inside one layer: + +- **Overview**: continuous bin colouring keyed on canonical microwave + bands (3.4 / 5.7 / 10 / 24 / 47 / 76 / 122 / 241 GHz). Cells with no + duct render transparent. +- **Band picker**: user selects one band; cells where + `cutoff_ghz ≤ band` get a single solid fill, else transparent. + +Click readout: `"Best duct cutoff: 18.4 GHz · 2 ducts · thickest 84 m"`. + +## Data plumbing + +Per cell we want a single scalar `duct_cutoff_ghz` = min cutoff across +all detected ducts. Both writers must emit it; the wire format is +unchanged otherwise. + +**Elixir derive path** (`Microwaveprop.Weather.WeatherLayers.derive/1`): +Iterate `row.duct_characteristics`. Read `min_freq_ghz` (atom or string) +from each entry; fall back to `Duct.min_trapped_frequency_ghz/1` if +only `thickness_m` + `m_deficit` are present; skip sounding-shape +entries that have neither. Take the minimum, return `nil` if none. + +**Rust derive path** (`prop_grid_rs::weather_scalar_file::derive_row`): +Add `duct_cutoff_ghz: Option` to `ScalarRow`. Read `cell.get +("best_duct_freq_ghz")` — `native_duct::reduce_grid_to_ducts` already +stashes it. No new computation needed. + +**ScalarFile @atom_keys**: add `"duct_cutoff_ghz"` so Elixir reads +deserialise into the atom-keyed shape that callers expect. + +**WeatherTileController @cell_fields**: add `:duct_cutoff_ghz` to the +binary cell-pack whitelist. The pack format itself doesn't need +changes — it's already a generic per-field f32 array list. + +**MapLayers**: register the new entry alongside `duct_strength`. + +## UI + +`WeatherMapLive` gets one extra assign, `:band_filter` (default `nil` = +overview mode, otherwise an integer GHz value from the band buttons). +When the selected layer is `"duct_cutoff_ghz"`, render an inline strip +of band buttons (`[All] [10] [24] [47] [76] [122] [241]`) below the +layer picker. Clicking a band sends `select_band_filter`, which updates +the assign and pushes `set_band_filter` to the JS hook. Clicking the +active band again resets to `nil`. The band filter state lives in +URL params (`?band=24`) so deep links work. + +JS hook (`WeatherMap`): add a colour mapper branch keyed on layer id +`"duct_cutoff_ghz"`. In overview mode, bin the value through the +canonical band thresholds; in band-picker mode, render a single fill +colour where `value ≤ selected_band` (NaN → transparent). The +existing point-detail readout uses the same value field. + +## Edge cases + +- Old ScalarFiles without `duct_cutoff_ghz`: field decodes to `nil` → + NaN on the wire → transparent. Resolves naturally as new hourly + scalar files are written. +- Cells with no ducts: `duct_cutoff_ghz: nil` → transparent (correct). +- Sounding-shape ducts (`%{"base", "strength"}` without + thickness/M-deficit): skipped in the Elixir derive. They don't + appear on a gridded HRRR map. +- Cutoff above the highest band (e.g. 800 GHz duct only): still binned + to the top band (≥ 241 GHz) so it doesn't fall off the colour scale. + +## Test plan + +**Elixir**: +- `WeatherLayersTest` — `derive/1` with a native-duct row returns the + min `min_freq_ghz`; with no ducts returns `nil`; with mixed + shapes prefers the computable one. +- `WeatherTileControllerTest` — binary pack includes the new field + when requested via `?layers=duct_cutoff_ghz`. +- `MapLayersTest` — `valid_id?/1` accepts `"duct_cutoff_ghz"`. + +**Rust**: +- `weather_scalar_file::derive_row` test passes a `CellValues` with + `best_duct_freq_ghz: 18.4` and asserts the field round-trips. + +**LiveView**: +- `WeatherMapLiveTest` — selecting the new layer renders the band + buttons; clicking 24 updates the assign and pushes the event. + +Out of scope for v1: HRDPS band cutoff layer (same plumbing applies, +add later), historical backfill of the new field into old +ScalarFiles (let hourly writes refresh). diff --git a/lib/microwaveprop/weather/map_layers.ex b/lib/microwaveprop/weather/map_layers.ex index c21cb99f..644277e9 100644 --- a/lib/microwaveprop/weather/map_layers.ex +++ b/lib/microwaveprop/weather/map_layers.ex @@ -144,6 +144,14 @@ defmodule Microwaveprop.Weather.MapLayers do group: "Ducting", desc: "Duct trapping strength in modified refractivity (M) units. > 10 M is moderate, > 20 M is strong. Stronger ducts trap a wider range of frequencies." + }, + %{ + id: "duct_cutoff_ghz", + label: "Duct Cutoff Band", + unit: "GHz", + group: "Ducting", + desc: + "Lowest microwave frequency the detected duct geometry can trap (Bean & Dutton waveguide cutoff). In Overview mode, cells are coloured by the highest amateur band the duct supports. Pick a band to mask cells that only support that band or lower." } ] diff --git a/lib/microwaveprop/weather/scalar_file.ex b/lib/microwaveprop/weather/scalar_file.ex index fbbadfdd..af3814e3 100644 --- a/lib/microwaveprop/weather/scalar_file.ex +++ b/lib/microwaveprop/weather/scalar_file.ex @@ -75,7 +75,8 @@ defmodule Microwaveprop.Weather.ScalarFile do "inversion_base_m", "ducting", "duct_base_m", - "duct_strength" + "duct_strength", + "duct_cutoff_ghz" ]) @type row :: %{required(:lat) => float(), required(:lon) => float(), optional(atom()) => term()} diff --git a/lib/microwaveprop/weather/weather_layers.ex b/lib/microwaveprop/weather/weather_layers.ex index c224d2e8..1ef23c78 100644 --- a/lib/microwaveprop/weather/weather_layers.ex +++ b/lib/microwaveprop/weather/weather_layers.ex @@ -1,6 +1,7 @@ defmodule Microwaveprop.Weather.WeatherLayers do @moduledoc "Derives scalar weather map layers from HRRR profile data." + alias Microwaveprop.Propagation.Duct alias Microwaveprop.Weather.SoundingParams @doc """ @@ -30,10 +31,49 @@ defmodule Microwaveprop.Weather.WeatherLayers do inversion_strength: inversion_strength(sorted), inversion_base_m: inversion_base_m(sorted), duct_base_m: duct_field(row.duct_characteristics, "base"), - duct_strength: duct_field(row.duct_characteristics, "strength") + duct_strength: duct_field(row.duct_characteristics, "strength"), + duct_cutoff_ghz: duct_cutoff_ghz(row.duct_characteristics) } end + # Lowest microwave frequency that any detected duct can trap — the + # "best operationally useful band" for the cell. Prefers the + # pre-computed `:min_freq_ghz` on native-shape ducts (string or atom + # key); falls back to `Duct.min_trapped_frequency_ghz/1` when only + # `thickness_m` + `m_deficit` are present; skips sounding-shape ducts + # that carry neither (these don't appear on the HRRR weather grid in + # practice but we tolerate them for safety). + defp duct_cutoff_ghz(nil), do: nil + defp duct_cutoff_ghz([]), do: nil + + defp duct_cutoff_ghz(ducts) when is_list(ducts) do + ducts + |> Enum.map(&duct_min_freq/1) + |> Enum.reject(&is_nil/1) + |> Enum.min(fn -> nil end) + end + + defp duct_min_freq(duct) when is_map(duct) do + case duct[:min_freq_ghz] || duct["min_freq_ghz"] do + f when is_number(f) and f > 0 -> f * 1.0 + _ -> duct_min_freq_from_geometry(duct) + end + end + + defp duct_min_freq(_), do: nil + + defp duct_min_freq_from_geometry(duct) do + thickness = duct[:thickness_m] || duct["thickness_m"] + deficit = duct[:m_deficit] || duct["m_deficit"] + + if valid_geometry?(thickness, deficit) do + Duct.min_trapped_frequency_ghz(%{thickness_m: thickness * 1.0, m_deficit: deficit * 1.0}) + end + end + + defp valid_geometry?(t, d) when is_number(t) and is_number(d) and t > 0 and d > 0, do: true + defp valid_geometry?(_, _), do: false + defp sort_profile(nil), do: [] defp sort_profile([]), do: [] diff --git a/lib/microwaveprop_web/controllers/weather_tile_controller.ex b/lib/microwaveprop_web/controllers/weather_tile_controller.ex index f581cb50..353c8bf1 100644 --- a/lib/microwaveprop_web/controllers/weather_tile_controller.ex +++ b/lib/microwaveprop_web/controllers/weather_tile_controller.ex @@ -14,7 +14,7 @@ defmodule MicrowavepropWeb.WeatherTileController do surface_refractivity refractivity_gradient bl_height pwat temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb lapse_rate mid_lapse_rate inversion_strength inversion_base_m - ducting duct_base_m duct_strength + ducting duct_base_m duct_strength duct_cutoff_ghz )a @spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t() diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index 8db5644c..420f591f 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -8,6 +8,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do @layers MapLayers.all() @default_layer MapLayers.default_id() + # Canonical amateur microwave bands for the duct-cutoff layer picker. + # Buttons render in this order; an "All" reset clears the filter. + @cutoff_bands [10, 24, 47, 76, 122, 241] + # Default map viewport. Used when no `lat`/`lon`/`zoom` URL params are # provided (the deep-link case). Centered on DFW (the project's home # area) at z=7 — same defaults the JS hook used to hardcode. @@ -46,6 +50,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do page_title: "Weather Map", layers: @layers, selected_layer: @default_layer, + cutoff_bands: @cutoff_bands, + band_filter: nil, valid_time: initial_vt, valid_times: valid_times, initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)), @@ -65,6 +71,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do @impl true def handle_params(params, _uri, socket) do selected = pick_layer(params["layer"], socket.assigns[:selected_layer]) + band = pick_band(params["band"], selected) # Update current viewport so subsequent push_patch URLs combine # `layer=` with whatever lat/lon/zoom the user has navigated to. @@ -75,6 +82,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do {:noreply, socket |> assign(:selected_layer, selected) + |> assign(:band_filter, band) |> assign(:current_lat, lat) |> assign(:current_lon, lon) |> assign(:current_zoom, zoom)} @@ -87,6 +95,18 @@ defmodule MicrowavepropWeb.WeatherMapLive do defp pick_layer(_layer, current) when is_binary(current), do: current defp pick_layer(_layer, _current), do: @default_layer + # Band filter only applies to the duct-cutoff layer; switching away + # from it drops the filter so a stale `?band=24` on the URL doesn't + # ghost-mask the next layer that lands on the same hook. + defp pick_band(band, "duct_cutoff_ghz") when is_binary(band) do + case Integer.parse(band) do + {n, ""} -> if n in @cutoff_bands, do: n + _ -> nil + end + end + + defp pick_band(_band, _layer), do: nil + # Parse `lat` / `lon` / `zoom` query params, falling back to defaults # when missing or out of range. Lat in [-90, 90], lon in [-180, 180], # zoom in [@min_zoom, @max_zoom]. A bad single param doesn't poison @@ -148,6 +168,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do # `select_layer`) tweak one field without losing the others. defp build_path(socket, overrides \\ []) do layer = Keyword.get(overrides, :layer, socket.assigns.selected_layer) + band = Keyword.get(overrides, :band, socket.assigns[:band_filter]) 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) @@ -155,13 +176,11 @@ defmodule MicrowavepropWeb.WeatherMapLive do # List form (not map) so the param order is stable — tests # asserting on the exact URL, and humans reading shareable links, # both benefit from layer first then viewport. + base = [{"layer", layer}] + band_params = if layer == "duct_cutoff_ghz" and is_integer(band), do: [{"band", band}], else: [] + query = - URI.encode_query([ - {"layer", layer}, - {"lat", lat}, - {"lon", lon}, - {"zoom", zoom} - ]) + URI.encode_query(base ++ band_params ++ [{"lat", lat}, {"lon", lon}, {"zoom", zoom}]) "/weather?#{query}" end @@ -169,12 +188,42 @@ defmodule MicrowavepropWeb.WeatherMapLive do @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))} + # Drop a lingering band filter when switching to a non-cutoff + # layer — keeps the URL tidy and avoids ghost-mask state. + band = if layer_id == "duct_cutoff_ghz", do: socket.assigns[:band_filter] + {:noreply, push_patch(socket, to: build_path(socket, layer: layer_id, band: band))} else {:noreply, socket} end end + # Click the active band again to reset to "All" (continuous overview). + def handle_event("select_band_filter", %{"band" => band_str}, socket) do + band = + case Integer.parse(band_str || "") do + {n, ""} -> if n in @cutoff_bands, do: n + _ -> nil + end + + next = if band == socket.assigns[:band_filter], do: nil, else: band + + socket = + socket + |> assign(:band_filter, next) + |> push_event("set_band_filter", %{band: next}) + + {:noreply, push_patch(socket, to: build_path(socket, band: next))} + end + + def handle_event("clear_band_filter", _params, socket) do + socket = + socket + |> assign(:band_filter, nil) + |> push_event("set_band_filter", %{band: nil}) + + {:noreply, push_patch(socket, to: build_path(socket, band: nil))} + end + # Debounced moveend/zoomend from the JS hook. Every viewport change # patches the URL so a refresh / share-link lands the user back at # the same view. Stays out of the LiveView assigns hot path — @@ -328,6 +377,41 @@ defmodule MicrowavepropWeb.WeatherMapLive do end end + attr :bands, :list, required: true + attr :selected, :any, default: nil + + defp cutoff_band_picker(assigns) do + ~H""" +
+
+ Filter to band +
+
+ + +
+
+ """ + end + @impl true def render(assigns) do ~H""" @@ -341,6 +425,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do phx-update="ignore" data-layers={Jason.encode!(@layers)} data-selected-layer={@selected_layer} + data-band-filter={@band_filter} data-valid-times={@initial_valid_times_json} data-selected-time={@initial_selected_time} data-initial-lat={@initial_center_lat} @@ -425,6 +510,12 @@ defmodule MicrowavepropWeb.WeatherMapLive do {layer_description(@layers, @selected_layer)} + <.cutoff_band_picker + :if={@selected_layer == "duct_cutoff_ghz"} + bands={@cutoff_bands} + selected={@band_filter} + /> +
+ <.cutoff_band_picker + :if={@selected_layer == "duct_cutoff_ghz"} + bands={@cutoff_bands} + selected={@band_filter} + /> + <%!-- Overlay toggles --%>