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 lat: number
lon: number lon: number
score: number score: number
} tier_color: string
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
} }
interface RoverMapHook extends ViewHook { interface RoverMapHook extends ViewHook {
map: L.Map
osmLayer: L.TileLayer
topoLayer: L.TileLayer | null
layersControl: L.Control.Layers
stations: Station[] stations: Station[]
stationMarkers: L.LayerGroup stationMarkers: L.LayerGroup
scoreOverlay: L.GridLayer | null homeMarker: L.CircleMarker | null
gridLookup: Map<string, number> driveCircle: L.Circle | null
colorScale: ColorScaleEntry[] qualityLayer: L.GridLayer | null
map: L.Map cellLookup: Map<string, Cell>
pushBounds: (this: RoverMapHook) => void homeLat: number
homeLon: number
driveRadiusKm: number
visibilityHandler: (() => void) | null 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 renderStations(this: RoverMapHook): void
fitBounds(this: RoverMapHook): void buildQualityLayer(this: RoverMapHook): L.GridLayer
reconnected(this: RoverMapHook): void setDriveRadius(this: RoverMapHook, km: number): void
destroyed(this: RoverMapHook): 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) { mounted(this: RoverMapHook) {
this.stations = [] this.stations = []
this.stationMarkers = L.layerGroup() this.stationMarkers = L.layerGroup()
this.scoreOverlay = null this.homeMarker = null
this.gridLookup = new Map() this.driveCircle = null
this.qualityLayer = null
this.cellLookup = new Map()
this.topoLayer = null
this.colorScale = [ const ds = this.el.dataset
{ min: 80, r: 0, g: 255, b: 163 }, this.homeLat = parseFloat(ds.homeLat || "32.5")
{ min: 65, r: 125, g: 255, b: 212 }, this.homeLon = parseFloat(ds.homeLon || "-97.5")
{ min: 50, r: 255, g: 229, b: 102 }, this.driveRadiusKm = parseFloat(ds.driveRadiusKm || "100")
{ min: 33, r: 255, g: 144, b: 68 },
{ min: 0, r: 255, g: 79, b: 79 }
]
const map = L.map(this.el, { const map = L.map(this.el, {
center: [33.2, -97.1], center: [this.homeLat, this.homeLon],
zoom: 7, zoom: 8,
zoomControl: true, zoomControl: true,
attributionControl: false attributionControl: false
}) })
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map)
this.map = 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) this.stationMarkers.addTo(map)
// Render pre-loaded propagation scores this.homeMarker = L.circleMarker([this.homeLat, this.homeLon], {
const initialScores: Score[] = JSON.parse(this.el.dataset.scores || "[]") radius: 7,
if (initialScores.length > 0) { color: "#ffffff",
this.renderScores(initialScores) 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 map.on("click", (e: L.LeafletMouseEvent) => {
this.pushBounds = () => { this.pushEvent("rover_cell_detail", {
const b = map.getBounds() lat: e.latlng.lat,
this.pushEvent("map_bounds", { lon: e.latlng.lng
south: b.getSouth(), north: b.getNorth(),
west: b.getWest(), east: b.getEast()
}) })
})
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
this.map.invalidateSize()
}
} }
document.addEventListener("visibilitychange", this.visibilityHandler)
map.on("moveend", () => {
this.pushBounds()
})
this.handleEvent("update_scores", ({ scores }: { scores: Score[] }) => {
this.renderScores(scores)
})
this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => { this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => {
this.stations = stations this.stations = stations
this.renderStations() this.renderStations()
}) })
// Refresh overlay when the tab returns to visibility. Tiles created while this.handleEvent("update_drive_radius", ({ km }: { km: number }) => {
// hidden paint blank if the retained gridLookup no longer covers the this.setDriveRadius(km)
// current viewport; re-fetching bounds triggers update_scores and a full })
// overlay rebuild.
this.visibilityHandler = () => { this.handleEvent("rover_results", (
if (document.visibilityState === "visible") { { cells, drive_radius_km }: { cells: Cell[]; drive_radius_km: number }
this.map.invalidateSize() ) => {
this.pushBounds() this.cellLookup = new Map()
for (const c of cells) {
this.cellLookup.set(cellKey(c.lat, c.lon), c)
} }
} if (!this.qualityLayer) {
document.addEventListener("visibilitychange", this.visibilityHandler) 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) { reconnected(this: RoverMapHook) {
if (this.map) { if (this.map) this.map.invalidateSize()
this.map.invalidateSize()
this.pushBounds()
}
}, },
destroyed(this: RoverMapHook) { destroyed(this: RoverMapHook) {
@ -125,28 +230,29 @@ export const RoverMap: RoverMapHook = {
} }
}, },
// --- Propagation score overlay (same as main map) --- setDriveRadius(this: RoverMapHook, km: number) {
this.driveRadiusKm = km
renderScores(this: RoverMapHook, scores: Score[]) { if (this.driveCircle) {
if (this.scoreOverlay) { this.driveCircle.setRadius(kmToM(km))
this.map.removeLayer(this.scoreOverlay) this.driveCircle.setLatLng([this.homeLat, this.homeLon])
this.scoreOverlay = null
} }
if (!scores || scores.length === 0) return },
this.gridLookup = new Map() renderStations(this: RoverMapHook) {
scores.forEach((s) => { this.stationMarkers.clearLayers()
const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}` for (const s of this.stations) {
this.gridLookup.set(key, s.score) const marker = L.marker([s.lat, s.lon], {
}) icon: buildStationDivIcon(s),
interactive: false,
const colorCache = new Array<string>(101) keyboard: false
for (let i = 0; i <= 100; i++) { })
const c = this.scoreColorRGB(i) this.stationMarkers.addLayer(marker)
colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)`
} }
},
buildQualityLayer(this: RoverMapHook): L.GridLayer {
const self = this const self = this
const OverlayClass = L.GridLayer.extend({ const OverlayClass = L.GridLayer.extend({
createTile(coords: L.Coords) { createTile(coords: L.Coords) {
const tile = document.createElement("canvas") const tile = document.createElement("canvas")
@ -155,123 +261,102 @@ export const RoverMap: RoverMapHook = {
tile.height = size.y tile.height = size.y
const ctx = tile.getContext("2d")! 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 nwPoint = coords.scaleBy(size)
const nw = this._map.unproject(nwPoint, coords.z) const nw = this._map.unproject(nwPoint, coords.z)
const se = this._map.unproject(nwPoint.add(size), coords.z) const se = this._map.unproject(nwPoint.add(size), coords.z)
const latPerPx = (nw.lat - se.lat) / size.y const latPerPx = (nw.lat - se.lat) / h // positive (lat decreases going down)
const lonPerPx = (se.lng - nw.lng) / size.x const lonPerPx = (se.lng - nw.lng) / w
const cellPxX = Math.max(1, Math.ceil(step / lonPerPx)) if (latPerPx <= 0 || lonPerPx <= 0) return tile
const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx)))
const absLatPerPx = Math.abs(latPerPx)
let lastColor: string | null = null // 0.125° grid spacing in pixels
for (let py = 0; py < size.y; py += cellPxY) { const cellPxX = 0.125 / lonPerPx
const lat = nw.lat - py * absLatPerPx const cellPxY = 0.125 / latPerPx
for (let px = 0; px < size.x; px += cellPxX) { const cellPx = Math.max(1, Math.min(cellPxX, cellPxY))
const lon = nw.lng + px * lonPerPx
const score = self.interpolateScore(lat, lon, step) const splatRadius = Math.ceil(3 * cellPx)
if (score !== null) { const sigma = 1.2 * cellPx
const color = colorCache[Math.max(0, Math.min(100, Math.round(score)))] const twoSigmaSq = 2 * sigma * sigma
if (color !== lastColor) {
ctx.fillStyle = color // padding cell radius for the candidate filter (3 cells in degrees)
lastColor = color const latPad = 3 * 0.125
} const lonPad = 3 * 0.125
ctx.fillRect(px, py, cellPxX, cellPxY) 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 return tile
} }
}) })
this.scoreOverlay = new OverlayClass({ opacity: 1.0 }) as L.GridLayer return 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)
}
} }
} 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()} @spec create_station(User.t(), map()) :: {:ok, FixedStation.t()} | {:error, Ecto.Changeset.t()}
def create_station(%User{} = user, attrs) do 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{user_id: user.id}
|> FixedStation.changeset(attrs_with_position) |> FixedStation.changeset(attrs_with_position)
@ -39,6 +39,19 @@ defmodule Microwaveprop.Rover do
|> maybe_enqueue_elevation() |> maybe_enqueue_elevation()
end 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()) :: @spec update_station(User.t(), Ecto.UUID.t(), map()) ::
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()} {:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_station(%User{} = user, id, attrs) do def update_station(%User{} = user, id, attrs) do

View file

@ -1,266 +1,778 @@
defmodule MicrowavepropWeb.RoverLive do defmodule MicrowavepropWeb.RoverLive do
@moduledoc """ @moduledoc """
Rover planner: enter stationary stations you want to work, select a band, Rover-planning page: enter the fixed stations you want to work, pick a
and the map shows the HRRR propagation heatmap with your stations plotted. 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 use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.Propagation alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.ModeThresholds
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead 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 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 @default_band 10_000
@initial_bounds %{ @default_mode :ssb
"south" => 29.5, @default_forecast_hour 0
"north" => 36.3, @default_max_drive_min 120
"west" => -101.5, @default_min_elev_gain 0
"east" => -92.5 @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 @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
_ = {fixed_stations, persisted?} = load_stations(socket)
if connected?(socket) do home = home_for(socket)
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") valid_times = Propagation.available_valid_times(@default_band)
end current_valid_time = Propagation.latest_valid_time(@default_band)
initial_scores = Propagation.latest_scores(@default_band, @initial_bounds)
{:ok, {:ok,
assign(socket, assign(socket,
page_title: "Rover Planner", page_title: "Rover Planner",
band_options: @band_options, bands: @bands,
band: @default_band, band: @default_band,
stations: [], mode: @default_mode,
station_input: "", forecast_hour: @default_forecast_hour,
resolving: false, max_drive_min: @default_max_drive_min,
url_loaded: false, drive_radius_km: drive_radius_km(@default_max_drive_min),
initial_scores_json: Jason.encode!(initial_scores), min_elev_gain: @default_min_elev_gain,
bounds: @initial_bounds 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 end
@impl true # ── Lifecycle helpers ────────────────────────────────────────────────
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
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 {Rover.default_stations(), false}
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
end end
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 Rover.list_stations(user)
def handle_event("add_station", %{"callsign" => input}, socket) do end
input = String.trim(input)
if input == "" do defp home_for(socket) do
{:noreply, socket} case current_user(socket) do
else %User{home_lat: lat, home_lon: lon, home_grid: grid, home_elevation_m: elev}
send(self(), {:resolve_station, input}) when is_float(lat) and is_float(lon) ->
{:noreply, assign(socket, resolving: true, station_input: "")} %{label: grid || home_label(lat, lon), grid: grid, lat: lat, lon: lon, elev_m: elev}
_ ->
@anonymous_home
end end
end end
def handle_event("remove_station", %{"index" => idx_str}, socket) do defp current_user(socket) do
idx = String.to_integer(idx_str) case socket.assigns[:current_scope] do
stations = List.delete_at(socket.assigns.stations, idx) %{user: %User{} = user} -> user
socket = assign(socket, stations: stations) _ -> nil
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) end
{:noreply, push_url(socket)}
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 def handle_event("select_band", %{"band" => band_str}, socket) do
band = String.to_integer(band_str) band = parse_int(band_str, @default_band)
scores = Propagation.latest_scores(band, socket.assigns.bounds) {:noreply, assign(socket, band: band)}
socket =
socket
|> assign(band: band)
|> push_event("update_scores", %{scores: scores})
{:noreply, push_url(socket)}
end end
def handle_event("map_bounds", bounds, socket) do def handle_event("select_mode", %{"mode" => mode_str}, socket) do
scores = Propagation.latest_scores(socket.assigns.band, bounds) {:noreply, assign(socket, mode: parse_mode(mode_str, socket.assigns.mode))}
socket =
socket
|> assign(:bounds, bounds)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end end
# ── Async ── def handle_event("set_forecast_hour", %{"value" => v}, socket) do
hour = v |> parse_int(@default_forecast_hour) |> clamp(0, 18)
def handle_info({:resolve_station_list, calls}, socket) do {:noreply, assign(socket, forecast_hour: hour)}
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}
end 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 @impl true
def handle_info({:resolve_station, input}, socket) do def handle_async(:scoring, {:ok, %{cells: cells, top_candidates: cands}}, socket) do
case resolve_station(input) do {:noreply,
{:ok, station} -> socket
stations = socket.assigns.stations ++ [station] |> assign(top_candidates: cands, scoring_loading: false)
socket = assign(socket, stations: stations, resolving: false) |> push_event("rover_results", %{
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) cells: cells,
{:noreply, push_url(socket)} drive_radius_km: socket.assigns.drive_radius_km
})}
end
{:error, _reason} -> def handle_async(:scoring, {:exit, reason}, socket) do
{:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")} 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
end end
def handle_info({:propagation_updated, _valid_times}, socket) do defp toggle_station(%{assigns: %{persisted?: true}} = socket, id) do
scores = Propagation.latest_scores(socket.assigns.band, socket.assigns.bounds) user = current_user(socket)
{:noreply, push_event(socket, "update_scores", %{scores: scores})}
end
# ── Helpers ── with {:ok, _} <- Rover.toggle_selected(user, id) do
{:ok, Rover.list_stations(user)}
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
end end
end end
defp push_url(socket) do defp toggle_station(socket, id) do
labels = Enum.map_join(socket.assigns.stations, ",", & &1.label) stations =
Enum.map(socket.assigns.fixed_stations, fn s ->
params = if station_id(s) == id, do: Map.put(s, :selected, !s.selected), else: s
then(%{"band" => to_string(socket.assigns.band)}, fn p ->
if labels == "", do: p, else: Map.put(p, "stations", labels)
end) end)
push_patch(socket, to: ~p"/rover?#{params}", replace: true) {:ok, stations}
end end
defp station_data(stations) do defp delete_station(%{assigns: %{persisted?: true}} = socket, id) do
Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end) user = current_user(socket)
with {:ok, _} <- Rover.delete_station(user, id) do
{:ok, Rover.list_stations(user)}
end
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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="relative w-screen h-screen overflow-hidden"> <Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-full">
<div <div class="flex flex-col h-[calc(100vh-3.5rem)] w-full">
id="rover-map" <header class="flex items-center justify-between h-11 px-4 bg-base-100 border-b border-base-300 shrink-0">
phx-hook="RoverMap" <div class="flex flex-col leading-tight">
phx-update="ignore" <span class="font-semibold text-sm">Rover planning</span>
data-band={@band} <span class="text-xs text-base-content/60">
data-scores={@initial_scores_json} {band_label(@band)} · {mode_label(@mode)} · Forecast +{@forecast_hour}h
class="absolute inset-0 z-0" </span>
> </div>
</div> <button
type="button"
<%!-- Sidebar --%> phx-click="calculate"
<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"> class="btn btn-primary btn-sm"
<div class="font-bold text-sm">Rover Planner</div> disabled={@scoring_loading}
>
<select phx-change="select_band" name="band" class="select select-bordered select-sm w-full"> <span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
<%= for {label, value} <- @band_options do %> {if @scoring_loading, do: "Calculating…", else: "Calculate"}
<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 %>
</button> </button>
</form> </header>
<%= if @stations != [] do %> <div class="flex flex-1 min-h-0">
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div> <div class="relative flex-1 min-w-0">
<div class="flex flex-wrap gap-1"> <div
<%= for {station, idx} <- Enum.with_index(@stations) do %> id="rover-map"
<div class="badge badge-outline gap-1"> phx-hook="RoverMap"
{station.label} phx-update="ignore"
<button data-home-lat={@home.lat}
type="button" data-home-lon={@home.lon}
phx-click="remove_station" data-drive-radius-km={@drive_radius_km}
phx-value-index={idx} data-stations={Jason.encode!(stations_for_compute(@fixed_stations))}
class="cursor-pointer opacity-50 hover:opacity-100" 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> </button>
</div> </div>
<% end %> </.sidebar_section>
</div>
<% end %> <.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>
</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
end end

View file

@ -3,109 +3,153 @@ defmodule MicrowavepropWeb.RoverLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
describe "GET /rover" do alias Microwaveprop.Repo
test "renders the Rover Planner sidebar + map hook", %{conn: conn} do alias Microwaveprop.Rover
{:ok, _view, html} = live(conn, ~p"/rover")
assert html =~ "Rover Planner" describe "anonymous mount" do
# Band selector + station form. test "renders the rover header strip + Calculate button", %{conn: conn} do
assert html =~ ~s(id="rover-map") {:ok, _view, html} = live(conn, ~p"/rover")
assert html =~ ~s(phx-hook="RoverMap") assert html =~ "Rover planning"
assert html =~ "Add station" assert html =~ ~s(phx-click="calculate")
# Default band is 10_000 MHz — check the corresponding option label. assert html =~ "Calculate"
assert html =~ "10 GHz"
end 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") {:ok, _view, html} = live(conn, ~p"/rover")
# BandConfig.band_options/0 currently includes these microwave bands. for call <- ~w(W5LUA W5HN N5XU) do
for label <- ~w(50MHz 144MHz 432MHz 1296MHz 10GHz 24GHz) do assert html =~ call, "expected default station #{call} in rover HTML"
nospace = label |> String.replace("MHz", " MHz") |> String.replace("GHz", " GHz")
assert html =~ nospace, "expected band option #{nospace} in rover HTML"
end end
end end
test "a maidenhead grid in the URL resolves a station synchronously", %{conn: conn} do test "does not persist anonymous station additions to the DB", %{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
{:ok, view, _html} = live(conn, ~p"/rover") {:ok, view, _html} = live(conn, ~p"/rover")
view view
|> element(~s(select[name="band"])) |> form(~s(form[phx-submit="add_station"]), %{"callsign" => "K5TEST", "grid" => "EM12"})
|> 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"})
|> render_submit() |> 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) html = render(view)
assert html =~ "EM00" assert html =~ "24 GHz"
refute_push_event(view, "rover_results", %{})
end 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") {:ok, view, _html} = live(conn, ~p"/rover")
html = render_hook(view, "set_forecast_hour", %{"value" => "5"})
view
|> form(~s(form[phx-submit="add_station"]), %{callsign: " "})
|> render_submit()
# The stations list stays empty. assert render(view) =~ "+5h"
refute html =~ "Stationary Stations" refute_push_event(view, "rover_results", %{})
end 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") {: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) # 120 min @ 65 km/h = 130 km
assert_push_event(view, "update_drive_radius", %{km: km})
# The handler pushes an update_scores event to the JS hook. assert_in_delta km, 130.0, 0.1
assert_push_event(view, "update_scores", %{scores: scores})
assert is_list(scores)
end 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") {:ok, view, _html} = live(conn, ~p"/rover")
# Simulate the propagation pipeline broadcast by sending the render_hook(view, "set_min_elev_gain", %{"value" => "150"})
# message directly to the LiveView process.
send(view.pid, {:propagation_updated, [DateTime.utc_now()]})
assert_push_event(view, "update_scores", %{scores: scores}) assert render(view) =~ "150 m above home"
assert is_list(scores) 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 end
end end