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
160 lines
4.3 KiB
JavaScript
160 lines
4.3 KiB
JavaScript
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)
|
||
}
|
||
}
|
||
}
|