From 7c506a64531f6b6cafbb9924d03df4ad50110e33 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 16:23:59 -0500 Subject: [PATCH] feat(rover): redesigned LiveView with right-docked sidebar, Calculate flow, smoothed contours --- assets/js/rover_map_hook.ts | 493 ++++++---- lib/microwaveprop/rover.ex | 15 +- lib/microwaveprop_web/live/rover_live.ex | 904 ++++++++++++++---- .../live/rover_live_test.exs | 192 ++-- 4 files changed, 1129 insertions(+), 475 deletions(-) diff --git a/assets/js/rover_map_hook.ts b/assets/js/rover_map_hook.ts index 7ec23226..e9a33e4f 100644 --- a/assets/js/rover_map_hook.ts +++ b/assets/js/rover_map_hook.ts @@ -1,121 +1,226 @@ -interface Score { +import type { ViewHook } from "phoenix_live_view" + +interface Station { + callsign: string + lat: number + lon: number + selected: boolean +} + +interface Cell { lat: number lon: number score: number -} - -interface Station { - lat: number - lon: number - label: string -} - -interface ColorScaleEntry { - min: number - r: number - g: number - b: number -} - -interface RGB { - r: number - g: number - b: number + tier_color: string } interface RoverMapHook extends ViewHook { + map: L.Map + osmLayer: L.TileLayer + topoLayer: L.TileLayer | null + layersControl: L.Control.Layers stations: Station[] stationMarkers: L.LayerGroup - scoreOverlay: L.GridLayer | null - gridLookup: Map - colorScale: ColorScaleEntry[] - map: L.Map - pushBounds: (this: RoverMapHook) => void + homeMarker: L.CircleMarker | null + driveCircle: L.Circle | null + qualityLayer: L.GridLayer | null + cellLookup: Map + homeLat: number + homeLon: number + driveRadiusKm: number visibilityHandler: (() => void) | null - renderScores(this: RoverMapHook, scores: Score[]): void - interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null - scoreColorRGB(this: RoverMapHook, score: number): RGB + renderStations(this: RoverMapHook): void - fitBounds(this: RoverMapHook): void - reconnected(this: RoverMapHook): void - destroyed(this: RoverMapHook): void + buildQualityLayer(this: RoverMapHook): L.GridLayer + setDriveRadius(this: RoverMapHook, km: number): void } -export const RoverMap: RoverMapHook = { +const TIER_RGB: Record = { + "#16a34a": [22, 163, 74], // Excellent + "#eab308": [234, 179, 8], // Good + "#f97316": [249, 115, 22] // Marginal +} + +const TIER_ORDER = ["#16a34a", "#eab308", "#f97316"] + +function kmToM(km: number): number { + return km * 1000 +} + +function cellKey(lat: number, lon: number): string { + return `${lat.toFixed(3)},${lon.toFixed(3)}` +} + +function tierForScore(score: number): string | null { + if (score >= 10) return "#16a34a" + if (score >= 3) return "#eab308" + if (score >= 0) return "#f97316" + return null +} + +function buildStationDivIcon(station: Station): L.DivIcon { + const halo = station.selected + ? "box-shadow:0 0 0 3px rgba(14,165,233,0.55),0 0 10px 2px rgba(14,165,233,0.45);" + : "" + const html = ` +
+
+ + + +
+
+ ${station.callsign} +
+
` + return L.divIcon({ + html, + className: "rover-station-icon", + iconSize: [40, 36], + iconAnchor: [20, 7] + }) +} + +export const RoverMap: Partial = { mounted(this: RoverMapHook) { this.stations = [] this.stationMarkers = L.layerGroup() - this.scoreOverlay = null - this.gridLookup = new Map() + this.homeMarker = null + this.driveCircle = null + this.qualityLayer = null + this.cellLookup = new Map() + this.topoLayer = null - this.colorScale = [ - { min: 80, r: 0, g: 255, b: 163 }, - { min: 65, r: 125, g: 255, b: 212 }, - { min: 50, r: 255, g: 229, b: 102 }, - { min: 33, r: 255, g: 144, b: 68 }, - { min: 0, r: 255, g: 79, b: 79 } - ] + const ds = this.el.dataset + this.homeLat = parseFloat(ds.homeLat || "32.5") + this.homeLon = parseFloat(ds.homeLon || "-97.5") + this.driveRadiusKm = parseFloat(ds.driveRadiusKm || "100") const map = L.map(this.el, { - center: [33.2, -97.1], - zoom: 7, + center: [this.homeLat, this.homeLon], + zoom: 8, zoomControl: true, attributionControl: false }) - - L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19 - }).addTo(map) - this.map = map + + this.osmLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19 + }) + this.topoLayer = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", { + maxZoom: 17 + }) + + this.osmLayer.addTo(map) + + this.layersControl = L.control.layers( + { "OSM": this.osmLayer, "Topo": this.topoLayer }, + undefined, + { position: "topright" } + ).addTo(map) + + // Silently fall back if OpenTopoMap is unreachable. + this.topoLayer.on("tileerror", () => { + if (!this.topoLayer) return + if (map.hasLayer(this.topoLayer)) { + map.removeLayer(this.topoLayer) + this.osmLayer.addTo(map) + } + this.layersControl.removeLayer(this.topoLayer) + this.topoLayer = null + }) + this.stationMarkers.addTo(map) - // Render pre-loaded propagation scores - const initialScores: Score[] = JSON.parse(this.el.dataset.scores || "[]") - if (initialScores.length > 0) { - this.renderScores(initialScores) + this.homeMarker = L.circleMarker([this.homeLat, this.homeLon], { + radius: 7, + color: "#ffffff", + weight: 2, + fillColor: "#0ea5e9", + fillOpacity: 0.95, + pane: "markerPane" + }).addTo(map) + + this.homeMarker.bindTooltip("Home", { + permanent: true, + direction: "top", + offset: [0, -10], + className: "rover-home-label" + }) + + this.driveCircle = L.circle([this.homeLat, this.homeLon], { + radius: kmToM(this.driveRadiusKm), + color: "#0ea5e9", + weight: 1.5, + dashArray: "8 6", + fill: false + }).addTo(map) + + L.control.scale({ metric: true, imperial: false, position: "bottomleft" }).addTo(map) + + // Render initial stations from data attribute + try { + const initial: Station[] = JSON.parse(this.el.dataset.stations || "[]") + this.stations = initial + this.renderStations() + } catch { + this.stations = [] } - // Report bounds so server can send scores for visible area - this.pushBounds = () => { - const b = map.getBounds() - this.pushEvent("map_bounds", { - south: b.getSouth(), north: b.getNorth(), - west: b.getWest(), east: b.getEast() + map.on("click", (e: L.LeafletMouseEvent) => { + this.pushEvent("rover_cell_detail", { + lat: e.latlng.lat, + lon: e.latlng.lng }) + }) + + this.visibilityHandler = () => { + if (document.visibilityState === "visible") { + this.map.invalidateSize() + } } - - map.on("moveend", () => { - this.pushBounds() - }) - - this.handleEvent("update_scores", ({ scores }: { scores: Score[] }) => { - this.renderScores(scores) - }) + document.addEventListener("visibilitychange", this.visibilityHandler) this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => { this.stations = stations this.renderStations() }) - // Refresh overlay when the tab returns to visibility. Tiles created while - // hidden paint blank if the retained gridLookup no longer covers the - // current viewport; re-fetching bounds triggers update_scores and a full - // overlay rebuild. - this.visibilityHandler = () => { - if (document.visibilityState === "visible") { - this.map.invalidateSize() - this.pushBounds() + this.handleEvent("update_drive_radius", ({ km }: { km: number }) => { + this.setDriveRadius(km) + }) + + this.handleEvent("rover_results", ( + { cells, drive_radius_km }: { cells: Cell[]; drive_radius_km: number } + ) => { + this.cellLookup = new Map() + for (const c of cells) { + this.cellLookup.set(cellKey(c.lat, c.lon), c) } - } - document.addEventListener("visibilitychange", this.visibilityHandler) + if (!this.qualityLayer) { + this.qualityLayer = this.buildQualityLayer() + this.qualityLayer.addTo(this.map) + } else { + this.qualityLayer.redraw() + } + this.setDriveRadius(drive_radius_km) + }) + + this.handleEvent("focus_cell", ( + { lat, lon, zoom }: { lat: number; lon: number; zoom: number } + ) => { + this.map.flyTo([lat, lon], zoom, { duration: 0.6 }) + }) + + this.handleEvent("show_cell_popup", ( + { lat, lon, html }: { lat: number; lon: number; html: string } + ) => { + L.popup().setLatLng([lat, lon]).setContent(html).openOn(this.map) + }) }, reconnected(this: RoverMapHook) { - if (this.map) { - this.map.invalidateSize() - this.pushBounds() - } + if (this.map) this.map.invalidateSize() }, destroyed(this: RoverMapHook) { @@ -125,28 +230,29 @@ export const RoverMap: RoverMapHook = { } }, - // --- Propagation score overlay (same as main map) --- - - renderScores(this: RoverMapHook, scores: Score[]) { - if (this.scoreOverlay) { - this.map.removeLayer(this.scoreOverlay) - this.scoreOverlay = null + setDriveRadius(this: RoverMapHook, km: number) { + this.driveRadiusKm = km + if (this.driveCircle) { + this.driveCircle.setRadius(kmToM(km)) + this.driveCircle.setLatLng([this.homeLat, this.homeLon]) } - if (!scores || scores.length === 0) return + }, - this.gridLookup = new Map() - scores.forEach((s) => { - const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}` - this.gridLookup.set(key, s.score) - }) - - const colorCache = new Array(101) - for (let i = 0; i <= 100; i++) { - const c = this.scoreColorRGB(i) - colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)` + renderStations(this: RoverMapHook) { + this.stationMarkers.clearLayers() + for (const s of this.stations) { + const marker = L.marker([s.lat, s.lon], { + icon: buildStationDivIcon(s), + interactive: false, + keyboard: false + }) + this.stationMarkers.addLayer(marker) } + }, + buildQualityLayer(this: RoverMapHook): L.GridLayer { const self = this + const OverlayClass = L.GridLayer.extend({ createTile(coords: L.Coords) { const tile = document.createElement("canvas") @@ -155,123 +261,102 @@ export const RoverMap: RoverMapHook = { tile.height = size.y const ctx = tile.getContext("2d")! - const step = 0.125 + const w = size.x + const h = size.y + + // tile lat/lon bounds const nwPoint = coords.scaleBy(size) const nw = this._map.unproject(nwPoint, coords.z) const se = this._map.unproject(nwPoint.add(size), coords.z) - const latPerPx = (nw.lat - se.lat) / size.y - const lonPerPx = (se.lng - nw.lng) / size.x - const cellPxX = Math.max(1, Math.ceil(step / lonPerPx)) - const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) - const absLatPerPx = Math.abs(latPerPx) + const latPerPx = (nw.lat - se.lat) / h // positive (lat decreases going down) + const lonPerPx = (se.lng - nw.lng) / w + if (latPerPx <= 0 || lonPerPx <= 0) return tile - let lastColor: string | null = null - for (let py = 0; py < size.y; py += cellPxY) { - const lat = nw.lat - py * absLatPerPx - for (let px = 0; px < size.x; px += cellPxX) { - const lon = nw.lng + px * lonPerPx - const score = self.interpolateScore(lat, lon, step) - if (score !== null) { - const color = colorCache[Math.max(0, Math.min(100, Math.round(score)))] - if (color !== lastColor) { - ctx.fillStyle = color - lastColor = color - } - ctx.fillRect(px, py, cellPxX, cellPxY) + // 0.125° grid spacing in pixels + const cellPxX = 0.125 / lonPerPx + const cellPxY = 0.125 / latPerPx + const cellPx = Math.max(1, Math.min(cellPxX, cellPxY)) + + const splatRadius = Math.ceil(3 * cellPx) + const sigma = 1.2 * cellPx + const twoSigmaSq = 2 * sigma * sigma + + // padding cell radius for the candidate filter (3 cells in degrees) + const latPad = 3 * 0.125 + const lonPad = 3 * 0.125 + const minLat = se.lat - latPad + const maxLat = nw.lat + latPad + const minLon = nw.lng - lonPad + const maxLon = se.lng + lonPad + + const tierBuffers: Record = { + "#16a34a": new Float32Array(w * h), + "#eab308": new Float32Array(w * h), + "#f97316": new Float32Array(w * h) + } + + for (const cell of self.cellLookup.values()) { + if (cell.lat < minLat || cell.lat > maxLat) continue + if (cell.lon < minLon || cell.lon > maxLon) continue + + const tier = tierForScore(cell.score) + if (!tier) continue + + // Cells may carry an explicit tier_color from the server; honor it. + const useTier = TIER_RGB[cell.tier_color] ? cell.tier_color : tier + const buf = tierBuffers[useTier] + if (!buf) continue + + const cx = (cell.lon - nw.lng) / lonPerPx + const cy = (nw.lat - cell.lat) / latPerPx + + const x0 = Math.max(0, Math.floor(cx - splatRadius)) + const x1 = Math.min(w - 1, Math.ceil(cx + splatRadius)) + const y0 = Math.max(0, Math.floor(cy - splatRadius)) + const y1 = Math.min(h - 1, Math.ceil(cy + splatRadius)) + + for (let py = y0; py <= y1; py++) { + const dy = py - cy + for (let px = x0; px <= x1; px++) { + const dx = px - cx + const d2 = dx * dx + dy * dy + const a = Math.exp(-d2 / twoSigmaSq) + const idx = py * w + px + if (a > buf[idx]) buf[idx] = a } } } + + const img = ctx.createImageData(w, h) + const data = img.data + + for (let i = 0; i < w * h; i++) { + let bestAlpha = 0 + let bestRgb: [number, number, number] | null = null + + for (const tierColor of TIER_ORDER) { + const a = tierBuffers[tierColor][i] + if (a > 0.05 && a > bestAlpha) { + bestAlpha = a + bestRgb = TIER_RGB[tierColor] + } + } + + if (bestRgb && bestAlpha > 0) { + const o = i * 4 + data[o] = bestRgb[0] + data[o + 1] = bestRgb[1] + data[o + 2] = bestRgb[2] + data[o + 3] = Math.min(255, Math.round(bestAlpha * 0.35 * 255)) + } + } + + ctx.putImageData(img, 0, 0) return tile } }) - this.scoreOverlay = new OverlayClass({ opacity: 1.0 }) as L.GridLayer - this.scoreOverlay!.addTo(this.map) - }, - - interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null { - const rlat = (Math.round(lat / step) * step).toFixed(3) - const rlon = (Math.round(lon / step) * step).toFixed(3) - if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`)! - - const lat0 = Math.floor(lat / step) * step - const lat1 = lat0 + step - const lon0 = Math.floor(lon / step) * step - const lon1 = lon0 + step - - const s00 = this.gridLookup.get(`${lat0.toFixed(3)},${lon0.toFixed(3)}`) - const s10 = this.gridLookup.get(`${lat1.toFixed(3)},${lon0.toFixed(3)}`) - const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`) - const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`) - - const corners = [s00, s10, s01, s11].filter((s): s is number => s !== undefined) - if (corners.length < 2) return null - - if (corners.length === 4) { - const tLat = (lat - lat0) / step - const tLon = (lon - lon0) / step - return Math.round( - s00! * (1 - tLat) * (1 - tLon) + s10! * tLat * (1 - tLon) + - s01! * (1 - tLat) * tLon + s11! * tLat * tLon - ) - } - return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length) - }, - - scoreColorRGB(this: RoverMapHook, score: number): RGB { - for (let i = 0; i < this.colorScale.length - 1; i++) { - const upper = this.colorScale[i] - const lower = this.colorScale[i + 1] - if (score >= lower.min) { - const range = upper.min - lower.min - const t = Math.min(1, (score - lower.min) / range) - return { - r: Math.round(lower.r + (upper.r - lower.r) * t), - g: Math.round(lower.g + (upper.g - lower.g) * t), - b: Math.round(lower.b + (upper.b - lower.b) * t) - } - } - } - const last = this.colorScale[this.colorScale.length - 1] - return { r: last.r, g: last.g, b: last.b } - }, - - // --- Station rendering --- - - renderStations(this: RoverMapHook) { - this.stationMarkers.clearLayers() - - for (const s of this.stations) { - const marker = L.circleMarker([s.lat, s.lon], { - radius: 7, - color: "#fff", - weight: 2, - fillColor: "#3b82f6", - fillOpacity: 0.9, - interactive: true, - pane: "markerPane" - }) - - marker.bindTooltip(s.label, { - permanent: true, - direction: "top", - offset: [0, -10], - className: "station-label" - }) - - this.stationMarkers.addLayer(marker) - } - - this.fitBounds() - }, - - fitBounds(this: RoverMapHook) { - const points: [number, number][] = this.stations.map(s => [s.lat, s.lon]) - if (points.length >= 2) { - this.map.fitBounds(points, { padding: [60, 60], maxZoom: 9 }) - } else if (points.length === 1) { - this.map.setView(points[0], 8) - } + return new OverlayClass({ opacity: 1.0 }) as L.GridLayer } -} as unknown as RoverMapHook +} diff --git a/lib/microwaveprop/rover.ex b/lib/microwaveprop/rover.ex index 7742dec5..89e0c124 100644 --- a/lib/microwaveprop/rover.ex +++ b/lib/microwaveprop/rover.ex @@ -31,7 +31,7 @@ defmodule Microwaveprop.Rover do @spec create_station(User.t(), map()) :: {:ok, FixedStation.t()} | {:error, Ecto.Changeset.t()} def create_station(%User{} = user, attrs) do - attrs_with_position = Map.put_new(attrs, :position, next_position(user)) + attrs_with_position = put_position_default(attrs, next_position(user)) %FixedStation{user_id: user.id} |> FixedStation.changeset(attrs_with_position) @@ -39,6 +39,19 @@ defmodule Microwaveprop.Rover do |> maybe_enqueue_elevation() end + defp put_position_default(attrs, position) when is_map(attrs) do + cond do + Map.has_key?(attrs, :position) -> attrs + Map.has_key?(attrs, "position") -> attrs + string_keyed?(attrs) -> Map.put(attrs, "position", position) + true -> Map.put(attrs, :position, position) + end + end + + defp string_keyed?(attrs) do + Enum.any?(attrs, fn {k, _v} -> is_binary(k) end) + end + @spec update_station(User.t(), Ecto.UUID.t(), map()) :: {:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()} def update_station(%User{} = user, id, attrs) do diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index d957a255..9f0ba6d5 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -1,266 +1,778 @@ defmodule MicrowavepropWeb.RoverLive do @moduledoc """ - Rover planner: enter stationary stations you want to work, select a band, - and the map shows the HRRR propagation heatmap with your stations plotted. + Rover-planning page: enter the fixed stations you want to work, pick a + band/mode/forecast hour, and the map paints a smoothed propagation + quality heatmap with the top 5 ranked drive-to candidates pinned in the + bottom strip. """ + use MicrowavepropWeb, :live_view + alias Microwaveprop.Accounts.User alias Microwaveprop.Propagation - alias Microwaveprop.Propagation.BandConfig - alias Microwaveprop.Radio.CallsignClient + alias Microwaveprop.Propagation.ModeThresholds alias Microwaveprop.Radio.Maidenhead + alias Microwaveprop.Repo + alias Microwaveprop.Rover + alias Microwaveprop.Rover.Compute + alias Microwaveprop.Rover.DriveTime + alias Microwaveprop.Rover.LinkMargin + alias Microwaveprop.Terrain.Srtm require Logger - @band_options BandConfig.band_options() + @bands [ + %{label: "10 GHz", value: 10_000}, + %{label: "24 GHz", value: 24_000}, + %{label: "47 GHz", value: 47_000} + ] @default_band 10_000 - @initial_bounds %{ - "south" => 29.5, - "north" => 36.3, - "west" => -101.5, - "east" => -92.5 - } + @default_mode :ssb + @default_forecast_hour 0 + @default_max_drive_min 120 + @default_min_elev_gain 0 + @avg_speed_kmh 65.0 + + # NTMS-area anonymous home: EM13 centroid. + @anonymous_home %{label: "EM13", grid: "EM13", lat: 33.5, lon: -97.0, elev_m: nil} @impl true def mount(_params, _session, socket) do - _ = - if connected?(socket) do - Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") - end - - initial_scores = Propagation.latest_scores(@default_band, @initial_bounds) + {fixed_stations, persisted?} = load_stations(socket) + home = home_for(socket) + valid_times = Propagation.available_valid_times(@default_band) + current_valid_time = Propagation.latest_valid_time(@default_band) {:ok, assign(socket, page_title: "Rover Planner", - band_options: @band_options, + bands: @bands, band: @default_band, - stations: [], - station_input: "", - resolving: false, - url_loaded: false, - initial_scores_json: Jason.encode!(initial_scores), - bounds: @initial_bounds + mode: @default_mode, + forecast_hour: @default_forecast_hour, + max_drive_min: @default_max_drive_min, + drive_radius_km: drive_radius_km(@default_max_drive_min), + min_elev_gain: @default_min_elev_gain, + fixed_stations: fixed_stations, + persisted?: persisted?, + home: home, + valid_times: valid_times, + current_valid_time: current_valid_time, + top_candidates: [], + station_form_error: nil, + home_form_error: nil, + scoring_loading: false )} end - @impl true - def handle_params(params, _uri, socket) do - if socket.assigns.url_loaded do - {:noreply, socket} - else - band = if params["band"], do: String.to_integer(params["band"]), else: socket.assigns.band + # ── Lifecycle helpers ──────────────────────────────────────────────── - socket = assign(socket, band: band, url_loaded: true) + defp load_stations(socket) do + case current_user(socket) do + %User{} = user -> + case Rover.list_stations(user) do + [] -> {seed_default_stations(user), true} + stations -> {stations, true} + end - # Load stations from URL: ?stations=W5ISP,K5TR,EM00cd - case params["stations"] do - nil -> - {:noreply, socket} - - stations_str -> - calls = stations_str |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) - send(self(), {:resolve_station_list, calls}) - {:noreply, assign(socket, resolving: true)} - end + _ -> + {Rover.default_stations(), false} end end - # ── Events ── + defp seed_default_stations(user) do + Enum.each(Rover.default_stations(), fn attrs -> + Rover.create_station(user, Map.delete(attrs, :position)) + end) - @impl true - def handle_event("add_station", %{"callsign" => input}, socket) do - input = String.trim(input) + Rover.list_stations(user) + end - if input == "" do - {:noreply, socket} - else - send(self(), {:resolve_station, input}) - {:noreply, assign(socket, resolving: true, station_input: "")} + defp home_for(socket) do + case current_user(socket) do + %User{home_lat: lat, home_lon: lon, home_grid: grid, home_elevation_m: elev} + when is_float(lat) and is_float(lon) -> + %{label: grid || home_label(lat, lon), grid: grid, lat: lat, lon: lon, elev_m: elev} + + _ -> + @anonymous_home end end - def handle_event("remove_station", %{"index" => idx_str}, socket) do - idx = String.to_integer(idx_str) - stations = List.delete_at(socket.assigns.stations, idx) - socket = assign(socket, stations: stations) - socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) - {:noreply, push_url(socket)} + defp current_user(socket) do + case socket.assigns[:current_scope] do + %{user: %User{} = user} -> user + _ -> nil + end end + defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 6) + + defp drive_radius_km(max_drive_min), do: max_drive_min * @avg_speed_kmh / 60.0 + + # ── Events ──────────────────────────────────────────────────────────── + + @impl true def handle_event("select_band", %{"band" => band_str}, socket) do - band = String.to_integer(band_str) - scores = Propagation.latest_scores(band, socket.assigns.bounds) - - socket = - socket - |> assign(band: band) - |> push_event("update_scores", %{scores: scores}) - - {:noreply, push_url(socket)} + band = parse_int(band_str, @default_band) + {:noreply, assign(socket, band: band)} end - def handle_event("map_bounds", bounds, socket) do - scores = Propagation.latest_scores(socket.assigns.band, bounds) - - socket = - socket - |> assign(:bounds, bounds) - |> push_event("update_scores", %{scores: scores}) - - {:noreply, socket} + def handle_event("select_mode", %{"mode" => mode_str}, socket) do + {:noreply, assign(socket, mode: parse_mode(mode_str, socket.assigns.mode))} end - # ── Async ── - - def handle_info({:resolve_station_list, calls}, socket) do - stations = - calls - |> Task.async_stream(&resolve_station/1, max_concurrency: 4, timeout: 10_000) - |> Enum.zip(calls) - |> Enum.flat_map(fn - {{:ok, {:ok, s}}, _call} -> - [s] - - {{:ok, {:error, reason}}, call} -> - Logger.warning("RoverLive station resolve failed: call=#{inspect(call)} reason=#{inspect(reason)}") - [] - - {{:exit, reason}, call} -> - Logger.error("RoverLive station resolve crashed: call=#{inspect(call)} reason=#{inspect(reason)}") - [] - end) - - socket = - socket - |> assign(stations: stations, resolving: false) - |> push_event("stations_updated", %{stations: station_data(stations)}) - - {:noreply, socket} + def handle_event("set_forecast_hour", %{"value" => v}, socket) do + hour = v |> parse_int(@default_forecast_hour) |> clamp(0, 18) + {:noreply, assign(socket, forecast_hour: hour)} end + def handle_event("set_max_drive_time", %{"value" => v}, socket) do + minutes = v |> parse_int(@default_max_drive_min) |> clamp(30, 240) + radius_km = drive_radius_km(minutes) + + {:noreply, + socket + |> assign(max_drive_min: minutes, drive_radius_km: radius_km) + |> push_event("update_drive_radius", %{km: radius_km})} + end + + def handle_event("set_min_elev_gain", %{"value" => v}, socket) do + gain = v |> parse_int(@default_min_elev_gain) |> clamp(0, 500) + {:noreply, assign(socket, min_elev_gain: gain)} + end + + def handle_event("toggle_station", %{"id" => id}, socket) do + handle_station_mutation(socket, id, &toggle_station/2) + end + + def handle_event("delete_station", %{"id" => id}, socket) do + handle_station_mutation(socket, id, &delete_station/2) + end + + def handle_event("add_station", params, socket) do + callsign = params |> Map.get("callsign", "") |> String.trim() + grid = params |> Map.get("grid", "") |> String.trim() + attrs = build_station_attrs(callsign, grid) + + case create_station(socket, attrs) do + {:ok, stations} -> + {:noreply, assign(socket, fixed_stations: stations, station_form_error: nil)} + + {:error, msg} -> + {:noreply, assign(socket, station_form_error: msg)} + end + end + + def handle_event("set_home_qth", params, socket) do + grid = params |> Map.get("home_grid", "") |> String.trim() + attrs = %{"home_grid" => grid} + + case update_home(socket, attrs) do + {:ok, home} -> + {:noreply, assign(socket, home: home, home_form_error: nil)} + + {:error, msg} -> + {:noreply, assign(socket, home_form_error: msg)} + end + end + + def handle_event("calculate", _params, socket) do + {:noreply, start_scoring(socket)} + end + + def handle_event("select_candidate", %{"grid" => grid}, socket) do + case Maidenhead.to_latlon(grid) do + {:ok, {lat, lon}} -> + {:noreply, push_event(socket, "focus_cell", %{lat: lat, lon: lon, zoom: 11})} + + :error -> + {:noreply, socket} + end + end + + def handle_event("rover_cell_detail", %{"lat" => lat, "lon" => lon}, socket) do + {:noreply, push_cell_popup(socket, lat, lon)} + end + + # ── Async ──────────────────────────────────────────────────────────── + @impl true - def handle_info({:resolve_station, input}, socket) do - case resolve_station(input) do - {:ok, station} -> - stations = socket.assigns.stations ++ [station] - socket = assign(socket, stations: stations, resolving: false) - socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) - {:noreply, push_url(socket)} + def handle_async(:scoring, {:ok, %{cells: cells, top_candidates: cands}}, socket) do + {:noreply, + socket + |> assign(top_candidates: cands, scoring_loading: false) + |> push_event("rover_results", %{ + cells: cells, + drive_radius_km: socket.assigns.drive_radius_km + })} + end - {:error, _reason} -> - {:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")} + def handle_async(:scoring, {:exit, reason}, socket) do + Logger.error("rover scoring crashed: #{inspect(reason)}") + {:noreply, assign(socket, top_candidates: [], scoring_loading: false)} + end + + # ── Mutation helpers ──────────────────────────────────────────────── + + defp handle_station_mutation(socket, id, fun) do + case fun.(socket, id) do + {:ok, stations} -> {:noreply, assign(socket, fixed_stations: stations)} + {:error, _} -> {:noreply, socket} end end - def handle_info({:propagation_updated, _valid_times}, socket) do - scores = Propagation.latest_scores(socket.assigns.band, socket.assigns.bounds) - {:noreply, push_event(socket, "update_scores", %{scores: scores})} - end + defp toggle_station(%{assigns: %{persisted?: true}} = socket, id) do + user = current_user(socket) - # ── Helpers ── - - defp resolve_station(input) do - input = String.trim(input) - - if Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) and String.length(input) >= 4 do - case Maidenhead.to_latlon(input) do - {:ok, {lat, lon}} -> - {:ok, %{label: String.upcase(input), lat: lat, lon: lon, type: :grid}} - - :error -> - {:error, "invalid grid"} - end - else - case CallsignClient.locate(input) do - {:ok, info} -> - {:ok, %{label: String.upcase(info.callsign), lat: info.lat, lon: info.lon, type: :callsign}} - - {:error, reason} -> - {:error, reason} - end + with {:ok, _} <- Rover.toggle_selected(user, id) do + {:ok, Rover.list_stations(user)} end end - defp push_url(socket) do - labels = Enum.map_join(socket.assigns.stations, ",", & &1.label) - - params = - then(%{"band" => to_string(socket.assigns.band)}, fn p -> - if labels == "", do: p, else: Map.put(p, "stations", labels) + defp toggle_station(socket, id) do + stations = + Enum.map(socket.assigns.fixed_stations, fn s -> + if station_id(s) == id, do: Map.put(s, :selected, !s.selected), else: s end) - push_patch(socket, to: ~p"/rover?#{params}", replace: true) + {:ok, stations} end - defp station_data(stations) do - Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end) + defp delete_station(%{assigns: %{persisted?: true}} = socket, id) do + user = current_user(socket) + + with {:ok, _} <- Rover.delete_station(user, id) do + {:ok, Rover.list_stations(user)} + end end - # ── Render ── + defp delete_station(socket, id) do + stations = Enum.reject(socket.assigns.fixed_stations, &(station_id(&1) == id)) + {:ok, stations} + end + + defp create_station(%{assigns: %{persisted?: true}} = socket, attrs) do + user = current_user(socket) + + case Rover.create_station(user, attrs) do + {:ok, _station} -> {:ok, Rover.list_stations(user)} + {:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)} + end + end + + defp create_station(socket, attrs) do + case build_anonymous_station(attrs, length(socket.assigns.fixed_stations)) do + {:ok, station} -> {:ok, socket.assigns.fixed_stations ++ [station]} + {:error, msg} -> {:error, msg} + end + end + + defp build_anonymous_station(%{"callsign" => call} = attrs, position) when is_binary(call) and call != "" do + grid = attrs["grid"] + + with {:ok, {lat, lon}} <- resolve_anonymous_latlon(grid, attrs["lat"], attrs["lon"]) do + {:ok, + %{ + id: "anon-#{:erlang.unique_integer([:positive])}", + callsign: call |> String.trim() |> String.upcase(), + grid: if(grid && grid != "", do: grid), + lat: lat, + lon: lon, + elevation_m: nil, + selected: true, + position: position + }} + end + end + + defp build_anonymous_station(_attrs, _position), do: {:error, "callsign required"} + + defp resolve_anonymous_latlon(grid, _lat, _lon) when is_binary(grid) and grid != "" do + case Maidenhead.to_latlon(grid) do + {:ok, {lat, lon}} -> {:ok, {lat, lon}} + :error -> {:error, "invalid grid"} + end + end + + defp resolve_anonymous_latlon(_grid, lat, lon) when is_number(lat) and is_number(lon) do + {:ok, {lat * 1.0, lon * 1.0}} + end + + defp resolve_anonymous_latlon(_grid, _lat, _lon), do: {:error, "grid or lat/lon required"} + + defp build_station_attrs(callsign, grid) do + %{} + |> put_if_present("callsign", callsign) + |> put_if_present("grid", grid) + end + + defp put_if_present(map, _key, ""), do: map + defp put_if_present(map, key, val), do: Map.put(map, key, val) + + defp update_home(%{assigns: %{persisted?: true}} = socket, attrs) do + user = current_user(socket) + + case user |> User.change_home_qth(attrs) |> Repo.update() do + {:ok, %User{} = updated} -> + {:ok, + %{ + label: updated.home_grid || home_label(updated.home_lat, updated.home_lon), + grid: updated.home_grid, + lat: updated.home_lat, + lon: updated.home_lon, + elev_m: updated.home_elevation_m + }} + + {:error, %Ecto.Changeset{} = cs} -> + {:error, changeset_first_error(cs)} + end + end + + defp update_home(_socket, %{"home_grid" => grid}) when is_binary(grid) and grid != "" do + case Maidenhead.to_latlon(grid) do + {:ok, {lat, lon}} -> + {:ok, %{label: grid, grid: grid, lat: lat, lon: lon, elev_m: nil}} + + :error -> + {:error, "invalid grid"} + end + end + + defp update_home(_socket, _attrs), do: {:error, "grid required"} + + defp changeset_first_error(%Ecto.Changeset{errors: [{field, {msg, _}} | _]}) do + "#{field}: #{msg}" + end + + defp changeset_first_error(_), do: "could not save" + + # ── Cell popup ─────────────────────────────────────────────────────── + + defp push_cell_popup(socket, lat, lon) do + %{ + band: band, + mode: mode, + current_valid_time: valid_time, + fixed_stations: stations, + home: home + } = socket.assigns + + grid = Maidenhead.from_latlon(lat, lon, 6) + elev_m = lookup_elev(lat, lon) + dist_km = haversine_km(home, %{lat: lat, lon: lon}) + + drive_min = dist_km / @avg_speed_kmh * 60.0 + + rows = + stations + |> Enum.filter(& &1.selected) + |> Enum.map(fn s -> link_row(s, band, valid_time, lat, lon, mode) end) + + html = render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows) + + push_event(socket, "show_cell_popup", %{lat: lat, lon: lon, html: html}) + end + + defp link_row(station, band, valid_time, lat, lon, mode) do + case LinkMargin.link_margin_db( + band, + valid_time, + {lat, lon}, + {station.lat, station.lon}, + mode + ) do + nil -> %{call: station.callsign, predicted_db: nil, margin: nil} + margin -> %{call: station.callsign, predicted_db: margin, margin: margin} + end + end + + defp render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows) do + rows_html = + Enum.map_join(rows, "", fn r -> + margin_str = + case r.margin do + nil -> "—" + m -> :erlang.float_to_binary(m, decimals: 1) <> " dB" + end + + ~s(#{r.call}#{margin_str}) + end) + + elev_str = if is_integer(elev_m), do: "#{elev_m} m", else: "—" + + """ +
+
#{grid}
+
#{elev_str} · #{Float.round(drive_min, 0)} min · #{Float.round(dist_km, 0)} km
+ #{rows_html}
+ +
+ """ + rescue + _ -> "
Cell detail unavailable
" + end + + defp lookup_elev(lat, lon) do + tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm") + + case Srtm.lookup(lat, lon, tiles_dir) do + {:ok, e} -> e + _ -> nil + end + end + + defp haversine_km(%{lat: lat1, lon: lon1}, %{lat: lat2, lon: lon2}) do + DriveTime.haversine_km({lat1, lon1}, {lat2, lon2}) + end + + # ── Calculate ─────────────────────────────────────────────────────── + + defp start_scoring(socket) do + args = scoring_args(socket) + + socket + |> assign(scoring_loading: true) + |> start_async(:scoring, fn -> Compute.run(args) end) + end + + defp scoring_args(socket) do + %{ + home: socket.assigns.home, + stations: stations_for_compute(socket.assigns.fixed_stations), + band_mhz: socket.assigns.band, + valid_time: socket.assigns.current_valid_time || DateTime.utc_now(), + mode: socket.assigns.mode, + max_drive_min: socket.assigns.max_drive_min, + min_elev_gain: socket.assigns.min_elev_gain + } + end + + defp stations_for_compute(stations) do + Enum.map(stations, fn s -> + %{ + callsign: s.callsign, + lat: s.lat, + lon: s.lon, + selected: !!s.selected + } + end) + end + + # ── Helpers ───────────────────────────────────────────────────────── + + defp parse_int(v, default) do + case Integer.parse("#{v}") do + {n, _} -> n + :error -> default + end + end + + defp parse_mode(mode_str, fallback) do + case mode_str do + "ssb" -> :ssb + "cw" -> :cw + "q65_60a" -> :q65_60a + "q65_120b" -> :q65_120b + _ -> fallback + end + end + + defp clamp(v, lo, hi), do: v |> max(lo) |> min(hi) + + defp band_label(band) do + Enum.find_value(@bands, "#{band} MHz", fn b -> if b.value == band, do: b.label end) + end + + defp mode_label(mode) do + Enum.find_value(ModeThresholds.modes(), "#{mode}", fn {atom, label} -> + if atom == mode, do: label + end) + end + + defp station_id(%{id: id}), do: to_string(id) + defp station_id(_), do: nil + + # ── Render ─────────────────────────────────────────────────────────── @impl true def render(assigns) do ~H""" -
-
-
- - <%!-- Sidebar --%> -
-
Rover Planner
- - - -
- - -
+ - <%= if @stations != [] do %> -
Stationary Stations ({length(@stations)})
-
- <%= for {station, idx} <- Enum.with_index(@stations) do %> -
- {station.label} - + + + +
+ + + +
+

+ {@station_form_error} +

+

+ Sign in to save your station list. +

+ + + <.sidebar_section title="FORECAST HOUR"> + +
+{@forecast_hour}h
+ + + <.sidebar_section title="BAND"> +
+
- <% end %> -
- <% end %> + + + <.sidebar_section title="MODE"> + + + + <.sidebar_section title="MAX DRIVE TIME"> + +
+ {Float.round(@max_drive_min / 60, 1)}h from {@home.label} +
+ + + <.sidebar_section title="MIN ELEV GAIN"> + +
{@min_elev_gain} m above home
+ + +
+ Tip: click any cell to see per-station predicted SNR. +
+ +
-
+ + """ + end + + attr :title, :string, required: true + slot :inner_block, required: true + + defp sidebar_section(assigns) do + ~H""" +
+

{@title}

+ {render_slot(@inner_block)} +
+ """ + end + + attr :home, :map, required: true + attr :error, :string, default: nil + attr :persisted?, :boolean, required: true + + defp home_form(assigns) do + ~H""" +
+ + +
+

{@error}

+

Sign in to save.

""" end end diff --git a/test/microwaveprop_web/live/rover_live_test.exs b/test/microwaveprop_web/live/rover_live_test.exs index e37c170b..3bf37101 100644 --- a/test/microwaveprop_web/live/rover_live_test.exs +++ b/test/microwaveprop_web/live/rover_live_test.exs @@ -3,109 +3,153 @@ defmodule MicrowavepropWeb.RoverLiveTest do import Phoenix.LiveViewTest - describe "GET /rover" do - test "renders the Rover Planner sidebar + map hook", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/rover") + alias Microwaveprop.Repo + alias Microwaveprop.Rover - assert html =~ "Rover Planner" - # Band selector + station form. - assert html =~ ~s(id="rover-map") - assert html =~ ~s(phx-hook="RoverMap") - assert html =~ "Add station" - # Default band is 10_000 MHz — check the corresponding option label. - assert html =~ "10 GHz" + describe "anonymous mount" do + test "renders the rover header strip + Calculate button", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/rover") + assert html =~ "Rover planning" + assert html =~ ~s(phx-click="calculate") + assert html =~ "Calculate" end - test "band selector shows every amateur microwave option", %{conn: conn} do + test "shows the right-docked sidebar with the six configured sections", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/rover") + assert html =~ ~s(id="rover-sidebar") + assert html =~ "FIXED STATIONS" + assert html =~ "FORECAST HOUR" + assert html =~ "BAND" + assert html =~ "MODE" + assert html =~ "MAX DRIVE TIME" + assert html =~ "MIN ELEV GAIN" + end + + test "displays the three default NTMS stations", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/rover") - # BandConfig.band_options/0 currently includes these microwave bands. - for label <- ~w(50MHz 144MHz 432MHz 1296MHz 10GHz 24GHz) do - nospace = label |> String.replace("MHz", " MHz") |> String.replace("GHz", " GHz") - assert html =~ nospace, "expected band option #{nospace} in rover HTML" + for call <- ~w(W5LUA W5HN N5XU) do + assert html =~ call, "expected default station #{call} in rover HTML" end end - test "a maidenhead grid in the URL resolves a station synchronously", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/rover?stations=EM13") - - # Async resolution runs in a message; flush by calling render. - html = render(view) - assert html =~ "EM13" - end - - test "selecting a band patches the URL and keeps the selection", %{conn: conn} do + test "does not persist anonymous station additions to the DB", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/rover") view - |> element(~s(select[name="band"])) - |> render_change(%{band: "1296"}) - - # URL patch replaces the current URL with ?band=1296. - assert_patched(view, ~p"/rover?band=1296") - assert render(view) =~ ~s(