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
193 lines
4.8 KiB
JavaScript
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)
|
|
}
|
|
}
|
|
}
|