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 this.scoreOverlay = null this.gridLookup = new Map() this.colorScale = [ { min: 80, r: 0, g: 255, b: 163 }, { min: 65, r: 125, g: 255, b: 212 }, { min: 50, r: 255, g: 229, b: 102 }, { min: 33, r: 255, g: 144, b: 68 }, { min: 0, r: 255, g: 79, b: 79 } ] 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) // Custom pane so grid lines render above coverage fills map.createPane("gridPane") map.getPane("gridPane").style.zIndex = 450 map.getPane("gridPane").style.pointerEvents = "none" this.map = map this.stationMarkers.addTo(map) this.coverageLayer.addTo(map) this.gridLayer.addTo(map) // Render pre-loaded propagation scores const initialScores = JSON.parse(this.el.dataset.scores || "[]") if (initialScores.length > 0) { this.renderScores(initialScores) } // Report bounds so server can send scores for visible area const pushBounds = () => { const b = map.getBounds() this.pushEvent("map_bounds", { south: b.getSouth(), north: b.getNorth(), west: b.getWest(), east: b.getEast() }) } // Draw grid and update on pan/zoom updateGridOverlay(map, this.gridLayer, { pane: "gridPane" }) map.on("moveend", () => { if (this.gridVisible) updateGridOverlay(map, this.gridLayer, { pane: "gridPane" }) pushBounds() }) this.handleEvent("update_scores", ({ scores }) => { this.renderScores(scores) }) this.handleEvent("stations_updated", ({ stations }) => { this.stations = stations this.renderStations() }) this.handleEvent("coverage_updated", ({ grids }) => { this.renderCoverage(grids) if (this.gridVisible) updateGridOverlay(map, this.gridLayer, { pane: "gridPane" }) }) this.handleEvent("toggle_grids", ({ show }) => { this.gridVisible = show if (show) { this.gridLayer.addTo(this.map) updateGridOverlay(this.map, this.gridLayer, { pane: "gridPane" }) } else { this.gridLayer.clearLayers() this.map.removeLayer(this.gridLayer) } }) }, // --- Propagation score overlay (same as main map) --- renderScores(scores) { if (this.scoreOverlay) { this.map.removeLayer(this.scoreOverlay) this.scoreOverlay = null } if (!scores || scores.length === 0) return this.gridLookup = new Map() scores.forEach((s) => { const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}` this.gridLookup.set(key, s.score) }) const colorCache = new Array(101) for (let i = 0; i <= 100; i++) { const c = this.scoreColorRGB(i) colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)` } const self = this const OverlayClass = L.GridLayer.extend({ createTile(coords) { const tile = document.createElement("canvas") const size = this.getTileSize() tile.width = size.x tile.height = size.y const ctx = tile.getContext("2d") const step = 0.125 const nwPoint = coords.scaleBy(size) const nw = this._map.unproject(nwPoint, coords.z) const se = this._map.unproject(nwPoint.add(size), coords.z) const latPerPx = (nw.lat - se.lat) / size.y const lonPerPx = (se.lng - nw.lng) / size.x const cellPxX = Math.max(1, Math.ceil(step / lonPerPx)) const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) const absLatPerPx = Math.abs(latPerPx) let lastColor = null for (let py = 0; py < size.y; py += cellPxY) { const lat = nw.lat - py * absLatPerPx for (let px = 0; px < size.x; px += cellPxX) { const lon = nw.lng + px * lonPerPx const score = self.interpolateScore(lat, lon, step) if (score !== null) { const color = colorCache[Math.max(0, Math.min(100, Math.round(score)))] if (color !== lastColor) { ctx.fillStyle = color lastColor = color } ctx.fillRect(px, py, cellPxX, cellPxY) } } } return tile } }) this.scoreOverlay = new OverlayClass({ opacity: 1.0 }) this.scoreOverlay.addTo(this.map) // Ensure coverage and grid layers stay above score overlay if (this.coverageLayer._map) this.coverageLayer.bringToFront() }, interpolateScore(lat, lon, step) { const rlat = (Math.round(lat / step) * step).toFixed(3) const rlon = (Math.round(lon / step) * step).toFixed(3) if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`) const lat0 = Math.floor(lat / step) * step const lat1 = lat0 + step const lon0 = Math.floor(lon / step) * step const lon1 = lon0 + step const s00 = this.gridLookup.get(`${lat0.toFixed(3)},${lon0.toFixed(3)}`) const s10 = this.gridLookup.get(`${lat1.toFixed(3)},${lon0.toFixed(3)}`) const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`) const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`) const corners = [s00, s10, s01, s11].filter(s => s !== undefined) if (corners.length < 2) return null if (corners.length === 4) { const tLat = (lat - lat0) / step const tLon = (lon - lon0) / step return Math.round( s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) + s01 * (1 - tLat) * tLon + s11 * tLat * tLon ) } return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length) }, scoreColorRGB(score) { for (let i = 0; i < this.colorScale.length - 1; i++) { const upper = this.colorScale[i] const lower = this.colorScale[i + 1] if (score >= lower.min) { const range = upper.min - lower.min const t = Math.min(1, (score - lower.min) / range) return { r: Math.round(lower.r + (upper.r - lower.r) * t), g: Math.round(lower.g + (upper.g - lower.g) * t), b: Math.round(lower.b + (upper.b - lower.b) * t) } } } const last = this.colorScale[this.colorScale.length - 1] return { r: last.r, g: last.g, b: last.b } }, // --- Station and coverage rendering --- 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" }, 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) } } }