prop/assets/js/rover_map_hook.js
Graham McIntire 7ffa20bee7 Add /rover planner for microwave contest rovers
Interactive map-based tool for planning rover operating positions.
Rovers enter stationary stations they want to work, then click the
map to evaluate candidate operating grids.

Features:
- Add stationary stations by callsign or grid square
- Click map to add operating positions (snaps to Maidenhead grid)
- Async SRTM terrain analysis: each stop computes terrain profile
  to every station, shows CLEAR/BLOCKED verdict + diffraction loss
- Terrain lines on map: green=workable, red=blocked, dashed=marginal
- Propagation score and 18-hour forecast sparkline per stop
- Route summary: driving distance, unique grid-station pairs
- Band selector affects range limits and propagation scores
- Numbered waypoint markers with score-colored backgrounds

New files:
- Geo module (shared haversine/bearing, used by PathLive too)
- RoverLive with fullscreen map + sidebar
- RoverMap JS hook with grid overlay, station/stop markers, route line
- Nav links in header and map control panel
2026-04-07 14:17:50 -05:00

193 lines
4.8 KiB
JavaScript

export const RoverMap = {
mounted() {
this.stations = []
this.stops = []
this.stationMarkers = L.layerGroup()
this.stopMarkers = L.layerGroup()
this.routeLine = null
this.terrainLines = L.layerGroup()
const map = L.map(this.el, {
center: [33.2, -97.1],
zoom: 7,
zoomControl: true,
attributionControl: false
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map)
this.map = map
this.stationMarkers.addTo(map)
this.stopMarkers.addTo(map)
this.terrainLines.addTo(map)
// Draw Maidenhead grid overlay
this.drawGridOverlay()
// Click to add stop
map.on("click", (e) => {
this.pushEvent("add_stop", { lat: e.latlng.lat, lon: e.latlng.lng })
})
// Handle server events
this.handleEvent("stations_updated", ({ stations }) => {
this.stations = stations
this.renderStations()
})
this.handleEvent("stops_updated", ({ stops }) => {
this.stops = stops
this.renderStops()
})
this.handleEvent("stop_terrain", ({ index, reachable }) => {
this.renderTerrainLines(index, reachable)
})
},
renderStations() {
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
})
marker.bindTooltip(s.label, {
permanent: true,
direction: "top",
offset: [0, -10],
className: "station-label"
})
this.stationMarkers.addLayer(marker)
}
this.fitBounds()
},
renderStops() {
this.stopMarkers.clearLayers()
this.terrainLines.clearLayers()
if (this.routeLine) {
this.map.removeLayer(this.routeLine)
this.routeLine = null
}
const routeCoords = []
for (const stop of this.stops) {
routeCoords.push([stop.lat, stop.lon])
const color = stop.score >= 65 ? "#00ffa3" : stop.score >= 50 ? "#ffe566" : stop.score >= 33 ? "#ff9044" : "#ff4f4f"
const marker = L.circleMarker([stop.lat, stop.lon], {
radius: 10,
color: "#fff",
weight: 3,
fillColor: color,
fillOpacity: 0.9,
interactive: true
})
// Number label
const icon = L.divIcon({
html: `<div style="
background: ${color};
color: #000;
width: 22px; height: 22px;
border-radius: 50%;
border: 2px solid white;
display: flex; align-items: center; justify-content: center;
font-weight: bold; font-size: 11px;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
">${stop.index + 1}</div>`,
className: "",
iconSize: [22, 22],
iconAnchor: [11, 11]
})
const iconMarker = L.marker([stop.lat, stop.lon], { icon, interactive: false })
iconMarker.bindTooltip(`${stop.grid} — Score: ${stop.score || "?"}, ${stop.reachable_count} workable`, {
direction: "top",
offset: [0, -14]
})
this.stopMarkers.addLayer(marker)
this.stopMarkers.addLayer(iconMarker)
}
// Route line
if (routeCoords.length >= 2) {
this.routeLine = L.polyline(routeCoords, {
color: "#fff",
weight: 3,
opacity: 0.7,
dashArray: "8 6"
}).addTo(this.map)
}
},
renderTerrainLines(stopIndex, reachable) {
const stop = this.stops[stopIndex]
if (!stop) return
for (const r of reachable) {
const color = r.workable ? "#00ffa3" : r.verdict === "BLOCKED" ? "#ff4f4f" : "#ffe566"
const line = L.polyline([[stop.lat, stop.lon], [r.lat, r.lon]], {
color,
weight: 1.5,
opacity: 0.5,
dashArray: r.workable ? null : "4 4"
})
line.bindTooltip(`${r.label}: ${r.dist_km} km, ${r.verdict || "?"}${r.diffraction_db > 0 ? ` (${r.diffraction_db} dB)` : ""}`)
this.terrainLines.addLayer(line)
}
},
drawGridOverlay() {
const gridLayer = L.layerGroup()
// Draw 4-char Maidenhead grid lines (2° lon x 1° lat)
for (let lat = 25; lat <= 50; lat += 1) {
L.polyline([[lat, -125], [lat, -66]], {
color: "#888",
weight: 0.5,
opacity: 0.3
}).addTo(gridLayer)
}
for (let lon = -125; lon <= -66; lon += 2) {
L.polyline([[25, lon], [50, lon]], {
color: "#888",
weight: 0.5,
opacity: 0.3
}).addTo(gridLayer)
}
gridLayer.addTo(this.map)
},
fitBounds() {
const allPoints = [
...this.stations.map(s => [s.lat, s.lon]),
...this.stops.map(s => [s.lat, s.lon])
]
if (allPoints.length >= 2) {
this.map.fitBounds(allPoints, { padding: [40, 40], maxZoom: 10 })
} else if (allPoints.length === 1) {
this.map.setView(allPoints[0], 8)
}
}
}