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.gridLayer = L.layerGroup().addTo(map) this.drawGridOverlay() this.handleEvent("stations_updated", ({ stations }) => { this.stations = stations this.renderStations() }) this.handleEvent("coverage_updated", ({ grids }) => { this.renderCoverage(grids) }) this.handleEvent("toggle_grids", ({ show }) => { if (show) { this.gridLayer.addTo(this.map) } else { 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( `${g.grid}
` + `Coverage: ${g.coverage_score}/100
` + `Stations: ${g.stations_in_range}/${g.total_stations} (${pctStations}%)
` + `Propagation: ${g.prop_score}/100` + (g.best_hour ? `
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: `
${i + 1}
`, 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" }, drawGridOverlay() { for (let lat = 25; lat <= 50; lat += 1) { L.polyline([[lat, -125], [lat, -66]], { color: "#888", weight: 0.5, opacity: 0.3 }).addTo(this.gridLayer) } for (let lon = -125; lon <= -66; lon += 2) { L.polyline([[25, lon], [50, lon]], { color: "#888", weight: 0.5, opacity: 0.3 }).addTo(this.gridLayer) } }, 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) } } }