feat(rover): redesigned LiveView with right-docked sidebar, Calculate flow, smoothed contours
This commit is contained in:
parent
448d3636a1
commit
7c506a6453
4 changed files with 1129 additions and 475 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue