prop/assets/js/rover_map_hook.js
Graham McIntire efe7103fe1 Use proper Maidenhead grid overlay on rover page
Extract GridSquare class and updateGridOverlay from propagation_map_hook
into shared maidenhead_grid.js module. Rover map now shows the same
zoom-adaptive Maidenhead grid overlay as the main /map page — red
rectangles with labels that switch between field (2-char) and square
(4-char) based on zoom level. Updates on pan/zoom.

Toggle button hides/shows the grid overlay.
2026-04-07 16:58:03 -05:00

181 lines
4.9 KiB
JavaScript

import { updateGridOverlay } from "./maidenhead_grid"
export const RoverMap = {
mounted() {
this.stations = []
this.stationMarkers = L.layerGroup()
this.coverageLayer = L.layerGroup()
this.gridLayer = L.layerGroup()
this.gridVisible = true
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.gridLayer.addTo(map)
// Draw grid and update on pan/zoom
updateGridOverlay(map, this.gridLayer)
map.on("moveend", () => {
if (this.gridVisible) updateGridOverlay(map, this.gridLayer)
})
this.handleEvent("stations_updated", ({ stations }) => {
this.stations = stations
this.renderStations()
})
this.handleEvent("coverage_updated", ({ grids }) => {
this.renderCoverage(grids)
})
this.handleEvent("toggle_grids", ({ show }) => {
this.gridVisible = show
if (show) {
this.gridLayer.addTo(this.map)
updateGridOverlay(this.map, this.gridLayer)
} else {
this.gridLayer.clearLayers()
this.map.removeLayer(this.gridLayer)
}
})
},
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) {
// Render as 0.25° cells centered on the candidate point
const half = 0.125
const bounds = [[g.lat - half, g.lon - half], [g.lat + half, g.lon + half]]
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)
}
}
}