feat(rover): redesigned LiveView with right-docked sidebar, Calculate flow, smoothed contours

This commit is contained in:
Graham McIntire 2026-04-25 16:23:59 -05:00
parent 448d3636a1
commit 7c506a6453
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 1129 additions and 475 deletions

View file

@ -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<string, number>
colorScale: ColorScaleEntry[]
map: L.Map
pushBounds: (this: RoverMapHook) => void
homeMarker: L.CircleMarker | null
driveCircle: L.Circle | null
qualityLayer: L.GridLayer | null
cellLookup: Map<string, Cell>
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<string, [number, number, number]> = {
"#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 = `
<div style="display:flex;flex-direction:column;align-items:center;pointer-events:none;">
<div style="${halo}border-radius:9999px;padding:2px;background:transparent;">
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
<polygon points="7,1 13,12 1,12" fill="#475569" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
</div>
<div style="font-size:10px;font-weight:600;color:#0f172a;background:rgba(255,255,255,0.85);padding:0 4px;border-radius:3px;margin-top:2px;white-space:nowrap;">
${station.callsign}
</div>
</div>`
return L.divIcon({
html,
className: "rover-station-icon",
iconSize: [40, 36],
iconAnchor: [20, 7]
})
}
export const RoverMap: Partial<RoverMapHook> = {
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<string>(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<string, Float32Array> = {
"#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
}

View file

@ -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

View file

@ -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(<tr><td style="padding-right:8px">#{r.call}</td><td style="text-align:right">#{margin_str}</td></tr>)
end)
elev_str = if is_integer(elev_m), do: "#{elev_m} m", else: ""
"""
<div style="font-size:12px;min-width:200px">
<div style="font-weight:600;font-size:13px">#{grid}</div>
<div style="opacity:0.7">#{elev_str} · #{Float.round(drive_min, 0)} min · #{Float.round(dist_km, 0)} km</div>
<table style="margin-top:6px;border-collapse:collapse;width:100%">#{rows_html}</table>
<div style="margin-top:6px;display:flex;gap:8px">
<a href="https://maps.apple.com/?q=#{grid}&ll=#{Float.round(:erlang.element(1, grid |> Maidenhead.to_latlon() |> elem(1)), 4)}" target="_blank" rel="noopener" style="color:#3b82f6">Open in Maps</a>
</div>
</div>
"""
rescue
_ -> "<div style=\"font-size:12px\">Cell detail unavailable</div>"
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"""
<div class="relative w-screen h-screen overflow-hidden">
<div
id="rover-map"
phx-hook="RoverMap"
phx-update="ignore"
data-band={@band}
data-scores={@initial_scores_json}
class="absolute inset-0 z-0"
>
</div>
<%!-- Sidebar --%>
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
<div class="font-bold text-sm">Rover Planner</div>
<select phx-change="select_band" name="band" class="select select-bordered select-sm w-full">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<form phx-submit="add_station" class="flex gap-1">
<input
type="text"
name="callsign"
value={@station_input}
placeholder="Add station (call or grid)"
class="input input-bordered input-sm flex-1"
/>
<button type="submit" class="btn btn-sm btn-primary" disabled={@resolving}>
<%= if @resolving do %>
<span class="loading loading-spinner loading-xs"></span>
<% else %>
Add
<% end %>
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-full">
<div class="flex flex-col h-[calc(100vh-3.5rem)] w-full">
<header class="flex items-center justify-between h-11 px-4 bg-base-100 border-b border-base-300 shrink-0">
<div class="flex flex-col leading-tight">
<span class="font-semibold text-sm">Rover planning</span>
<span class="text-xs text-base-content/60">
{band_label(@band)} · {mode_label(@mode)} · Forecast +{@forecast_hour}h
</span>
</div>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm"
disabled={@scoring_loading}
>
<span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
{if @scoring_loading, do: "Calculating…", else: "Calculate"}
</button>
</form>
</header>
<%= if @stations != [] do %>
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div>
<div class="flex flex-wrap gap-1">
<%= for {station, idx} <- Enum.with_index(@stations) do %>
<div class="badge badge-outline gap-1">
{station.label}
<button
type="button"
phx-click="remove_station"
phx-value-index={idx}
class="cursor-pointer opacity-50 hover:opacity-100"
<div class="flex flex-1 min-h-0">
<div class="relative flex-1 min-w-0">
<div
id="rover-map"
phx-hook="RoverMap"
phx-update="ignore"
data-home-lat={@home.lat}
data-home-lon={@home.lon}
data-drive-radius-km={@drive_radius_km}
data-stations={Jason.encode!(stations_for_compute(@fixed_stations))}
class={[
"absolute inset-0 z-0 transition-opacity",
@scoring_loading && "opacity-60"
]}
>
</div>
<div
:if={@top_candidates != []}
class="absolute bottom-0 inset-x-0 h-[110px] bg-base-100/90 backdrop-blur border-t border-base-300 z-10 overflow-x-auto"
>
<div class="carousel carousel-start gap-3 p-3 h-full">
<div
:for={c <- @top_candidates}
class="carousel-item w-[280px] card bg-base-200 shadow-sm cursor-pointer"
phx-click="select_candidate"
phx-value-grid={c.grid}
>
x
<div class="card-body p-3">
<div class="flex items-center justify-between">
<span class="font-mono font-semibold">{c.grid}</span>
<span
class="badge badge-sm font-mono"
style={"background-color: #{c.tier_color}; color: #000"}
>
{Float.round(c.score, 1)} dB
</span>
</div>
<div class="text-xs opacity-70">
{round(c.distance_km)} km {c.bearing_compass} of home
</div>
<div class="text-xs opacity-70">
{c.elev_m} m · {round(c.drive_min)} min drive
</div>
</div>
</div>
</div>
</div>
<div class="absolute bottom-[122px] left-4 z-20 bg-base-100/90 rounded-box shadow p-2 text-xs flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #16a34a; opacity: 0.5">
</span>
<span>Excellent 10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #eab308; opacity: 0.5">
</span>
<span>Good 3..10 dB</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-sm" style="background-color: #f97316; opacity: 0.5">
</span>
<span>Marginal 0..3 dB</span>
</div>
<div class="flex items-center gap-2 pt-1 border-t border-base-300/50">
<span class="w-4 border-t-2 border-dashed border-sky-500"></span>
<span>Drive radius</span>
</div>
</div>
</div>
<aside
id="rover-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-72 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-y-auto"
>
<.sidebar_section title="HOME">
<.home_form home={@home} error={@home_form_error} persisted?={@persisted?} />
</.sidebar_section>
<.sidebar_section title="FIXED STATIONS">
<ul class="menu menu-sm p-0 gap-0.5">
<li :for={s <- @fixed_stations}>
<label class="cursor-pointer flex items-center gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-xs"
checked={s.selected}
phx-click="toggle_station"
phx-value-id={station_id(s)}
/>
<span class="font-mono text-xs flex-1">{s.callsign}</span>
<span :if={s.grid} class="opacity-60 text-[11px]">{s.grid}</span>
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100"
phx-click="delete_station"
phx-value-id={station_id(s)}
title="Remove"
>
×
</button>
</label>
</li>
</ul>
<form phx-submit="add_station" class="flex gap-1 mt-2">
<input
type="text"
name="callsign"
placeholder="Call"
class="input input-bordered input-xs flex-1"
autocomplete="off"
/>
<input
type="text"
name="grid"
placeholder="Grid"
class="input input-bordered input-xs w-20"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs btn-primary">+</button>
</form>
<p :if={@station_form_error} class="text-error text-[11px] mt-1">
{@station_form_error}
</p>
<p :if={!@persisted?} class="text-[11px] opacity-50 mt-2">
Sign in to save your station list.
</p>
</.sidebar_section>
<.sidebar_section title="FORECAST HOUR">
<input
type="range"
min="0"
max="18"
step="1"
value={@forecast_hour}
phx-change="set_forecast_hour"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">+{@forecast_hour}h</div>
</.sidebar_section>
<.sidebar_section title="BAND">
<div class="join w-full">
<button
:for={b <- @bands}
type="button"
phx-click="select_band"
phx-value-band={b.value}
class={[
"btn btn-sm join-item flex-1",
@band == b.value && "btn-primary"
]}
>
{b.label |> String.replace(" GHz", "G")}
</button>
</div>
<% end %>
</div>
<% end %>
</.sidebar_section>
<.sidebar_section title="MODE">
<select
name="mode"
phx-change="select_mode"
class="select select-sm select-bordered w-full"
>
<option
:for={{atom, label} <- ModeThresholds.modes()}
value={Atom.to_string(atom)}
selected={atom == @mode}
>
{label}
</option>
</select>
</.sidebar_section>
<.sidebar_section title="MAX DRIVE TIME">
<input
type="range"
min="30"
max="240"
step="15"
value={@max_drive_min}
phx-change="set_max_drive_time"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">
{Float.round(@max_drive_min / 60, 1)}h from {@home.label}
</div>
</.sidebar_section>
<.sidebar_section title="MIN ELEV GAIN">
<input
type="range"
min="0"
max="500"
step="25"
value={@min_elev_gain}
phx-change="set_min_elev_gain"
phx-debounce="200"
name="value"
class="range range-xs range-primary"
/>
<div class="text-xs opacity-70 mt-1">{@min_elev_gain} m above home</div>
</.sidebar_section>
<div class="mt-auto p-3 text-[11px] text-neutral-content/50 border-t border-base-300/50">
Tip: click any cell to see per-station predicted SNR.
</div>
</aside>
</div>
</div>
</div>
</Layouts.app>
"""
end
attr :title, :string, required: true
slot :inner_block, required: true
defp sidebar_section(assigns) do
~H"""
<section class="px-3 py-3 border-t border-base-300/50 first:border-t-0">
<h3 class="text-[10px] uppercase tracking-wider text-neutral-content/60 mb-2">{@title}</h3>
{render_slot(@inner_block)}
</section>
"""
end
attr :home, :map, required: true
attr :error, :string, default: nil
attr :persisted?, :boolean, required: true
defp home_form(assigns) do
~H"""
<form phx-submit="set_home_qth" class="flex gap-1">
<input
type="text"
name="home_grid"
value={@home.grid || @home.label}
placeholder="Home grid"
class="input input-bordered input-xs flex-1 font-mono"
autocomplete="off"
/>
<button type="submit" class="btn btn-xs">Set</button>
</form>
<p :if={@error} class="text-error text-[11px] mt-1">{@error}</p>
<p :if={!@persisted?} class="text-[11px] opacity-50 mt-1">Sign in to save.</p>
"""
end
end

View file

@ -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(<option value="1296" selected)
end
test "removing a station clears it from the badge list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover?stations=EM13")
render(view)
assert render(view) =~ "EM13"
view
|> element(~s(button[phx-click="remove_station"]))
|> render_click(%{index: "0"})
refute render(view) =~ "EM13"
end
test "add_station event with a grid resolves via the direct maidenhead path", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
# Submit the add-station form. add_station sends a message to
# self() and toggles resolving: true; after the message is handled
# the station appears.
view
|> form(~s(form[phx-submit="add_station"]), %{callsign: "EM00"})
|> form(~s(form[phx-submit="add_station"]), %{"callsign" => "K5TEST", "grid" => "EM12"})
|> render_submit()
# Drive the resolver message asynchronously.
assert render(view) =~ "K5TEST"
assert Repo.aggregate(Microwaveprop.Rover.FixedStation, :count, :id) == 0
end
test "select_band updates the displayed band without recalculating", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
render_hook(view, "select_band", %{"band" => "24000"})
html = render(view)
assert html =~ "EM00"
assert html =~ "24 GHz"
refute_push_event(view, "rover_results", %{})
end
test "add_station with blank input is a no-op (no station added, no crash)", %{conn: conn} do
test "set_forecast_hour updates label only", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
html =
view
|> form(~s(form[phx-submit="add_station"]), %{callsign: " "})
|> render_submit()
render_hook(view, "set_forecast_hour", %{"value" => "5"})
# The stations list stays empty.
refute html =~ "Stationary Stations"
assert render(view) =~ "+5h"
refute_push_event(view, "rover_results", %{})
end
test "map_bounds event re-fetches propagation scores for the new viewport", %{conn: conn} do
test "set_max_drive_time updates the dashed circle radius", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
new_bounds = %{"south" => 30.0, "north" => 34.0, "west" => -100.0, "east" => -95.0}
render_hook(view, "set_max_drive_time", %{"value" => "120"})
render_hook(view, "map_bounds", new_bounds)
# The handler pushes an update_scores event to the JS hook.
assert_push_event(view, "update_scores", %{scores: scores})
assert is_list(scores)
# 120 min @ 65 km/h = 130 km
assert_push_event(view, "update_drive_radius", %{km: km})
assert_in_delta km, 130.0, 0.1
end
test "propagation_updated pubsub message pushes an update_scores event", %{conn: conn} do
test "set_min_elev_gain updates the slider label", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
# Simulate the propagation pipeline broadcast by sending the
# message directly to the LiveView process.
send(view.pid, {:propagation_updated, [DateTime.utc_now()]})
render_hook(view, "set_min_elev_gain", %{"value" => "150"})
assert_push_event(view, "update_scores", %{scores: scores})
assert is_list(scores)
assert render(view) =~ "150 m above home"
end
test "calculate triggers a rover_results push_event with cells + drive_radius_km", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
render_hook(view, "calculate", %{})
assert_push_event(view, "rover_results", payload, 5_000)
assert is_list(payload.cells)
assert is_number(payload.drive_radius_km)
end
test "select_candidate pushes focus_cell with lat/lon/zoom", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
# Inject a synthetic top candidate by calling calculate first; in the
# absence of real grid data the candidate list may be empty, so we
# exercise the event handler directly with a known grid.
render_hook(view, "select_candidate", %{"grid" => "EM13qc"})
assert_push_event(view, "focus_cell", %{lat: lat, lon: lon, zoom: 11})
assert_in_delta lat, 33.10, 0.1
assert_in_delta lon, -96.62, 0.1
end
end
describe "logged-in mount" do
setup :register_and_log_in_user
test "first visit seeds the three default stations to the DB", %{conn: conn, user: user} do
assert Rover.list_stations(user) == []
{:ok, _view, html} = live(conn, ~p"/rover")
stations = Rover.list_stations(user)
assert length(stations) == 3
assert stations |> Enum.map(& &1.callsign) |> Enum.sort() == ~w(N5XU W5HN W5LUA)
assert html =~ "W5LUA"
end
test "second visit reuses the persisted station list", %{conn: conn, user: user} do
{:ok, _view, _html} = live(conn, ~p"/rover")
first = Rover.list_stations(user)
{:ok, _view, _html} = live(conn, ~p"/rover")
second = Rover.list_stations(user)
assert Enum.map(first, & &1.id) == Enum.map(second, & &1.id)
end
test "add_station persists across reloads", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")
view
|> form(~s(form[phx-submit="add_station"]), %{"callsign" => "K5TX", "grid" => "EM13"})
|> render_submit()
assert Enum.any?(Rover.list_stations(user), &(&1.callsign == "K5TX"))
end
test "set_home_qth persists the user's home QTH", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")
view
|> form(~s(form[phx-submit="set_home_qth"]), %{"home_grid" => "EM13qc"})
|> render_submit()
reloaded = Repo.reload(user)
assert reloaded.home_grid == "EM13qc"
assert is_float(reloaded.home_lat)
assert is_float(reloaded.home_lon)
end
end
end