prop/assets/js/rover_map_hook.js
Graham McIntire 70521c37f2 Rover planner: auto-compute coverage areas with terrain + propagation
Rewrites rover planner to automatically color the map after stations
are entered. No manual stop selection needed — the system evaluates
every Maidenhead grid in range and scores them by:

- SRTM terrain analysis to each station (diffraction loss, LOS verdict)
- HRRR propagation forecast (ducting, refractivity, atmospheric conditions)
- Distance to stations vs band range limits
- Propagation boost: good conditions (score 80+) make blocked paths
  viable through ducting, matching the app's core beyond-LOS prediction

Coverage score formula: boosted path quality 30% + propagation 30%
+ distance 20% + station count 20%. Colored grid squares on the map
show best (green) to worst (red) operating positions.

Sidebar shows top 10 grids ranked with: workable station count,
propagation score, best operating hour, 18-hour forecast sparkline.
Click a grid for per-station detail: distance, terrain verdict,
diffraction loss.

URL stores stations + band for sharing: /rover?stations=W5ISP,K5TR&band=10000
2026-04-07 15:43:26 -05:00

160 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const RoverMap = {
mounted() {
this.stations = []
this.stationMarkers = L.layerGroup()
this.coverageLayer = 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.coverageLayer.addTo(map)
this.handleEvent("stations_updated", ({ stations }) => {
this.stations = stations
this.renderStations()
})
this.handleEvent("coverage_updated", ({ grids }) => {
this.renderCoverage(grids)
})
},
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,
pane: "markerPane"
})
marker.bindTooltip(s.label, {
permanent: true,
direction: "top",
offset: [0, -10],
className: "station-label"
})
this.stationMarkers.addLayer(marker)
}
this.fitBounds()
},
renderCoverage(grids) {
this.coverageLayer.clearLayers()
if (!grids || grids.length === 0) return
const maxScore = Math.max(...grids.map(g => g.coverage_score))
for (const g of grids) {
// 4-char Maidenhead: 2° lon × 1° lat
// Compute grid square bounds from the grid letters
const bounds = this.gridBounds(g.grid)
if (!bounds) continue
const normalized = maxScore > 0 ? g.coverage_score / maxScore : 0
const color = this.coverageColor(normalized)
const opacity = 0.15 + normalized * 0.45
const rect = L.rectangle(bounds, {
color: color,
weight: 1,
opacity: 0.5,
fillColor: color,
fillOpacity: opacity,
interactive: true
})
const pctStations = Math.round(g.stations_in_range / g.total_stations * 100)
rect.bindTooltip(
`<b>${g.grid}</b><br>` +
`Coverage: ${g.coverage_score}/100<br>` +
`Stations: ${g.stations_in_range}/${g.total_stations} (${pctStations}%)<br>` +
`Propagation: ${g.prop_score}/100` +
(g.best_hour ? `<br>Best: ${g.best_hour} UTC` : ""),
{ sticky: true }
)
rect.on("click", () => {
this.pushEvent("select_grid", { grid: g.grid })
})
this.coverageLayer.addLayer(rect)
}
// Add rank labels to top 5
const top5 = grids.slice(0, 5)
for (let i = 0; i < top5.length; i++) {
const g = top5[i]
const icon = L.divIcon({
html: `<div style="
background: ${this.coverageColor(1 - i * 0.15)};
color: #000;
width: 24px; height: 24px;
border-radius: 50%;
border: 2px solid white;
display: flex; align-items: center; justify-content: center;
font-weight: bold; font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.4);
">${i + 1}</div>`,
className: "",
iconSize: [24, 24],
iconAnchor: [12, 12]
})
L.marker([g.lat, g.lon], { icon, interactive: false, pane: "markerPane" })
.addTo(this.coverageLayer)
}
},
gridBounds(grid) {
// Decode 4-char Maidenhead to lat/lon bounds
if (grid.length < 4) return null
const lonField = grid.charCodeAt(0) - 65 // A=0
const latField = grid.charCodeAt(1) - 65
const lonSq = parseInt(grid[2])
const latSq = parseInt(grid[3])
const lon1 = lonField * 20 - 180 + lonSq * 2
const lat1 = latField * 10 - 90 + latSq * 1
const lon2 = lon1 + 2
const lat2 = lat1 + 1
return [[lat1, lon1], [lat2, lon2]]
},
coverageColor(normalized) {
// Green (best) → Yellow → Red (worst)
if (normalized >= 0.7) return "#00ffa3"
if (normalized >= 0.5) return "#7dffd4"
if (normalized >= 0.3) return "#ffe566"
if (normalized >= 0.15) return "#ff9044"
return "#ff4f4f"
},
fitBounds() {
const points = 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)
}
}
}