export const RoverMap = { mounted() { this.stations = [] this.stops = [] this.stationMarkers = L.layerGroup() this.stopMarkers = L.layerGroup() this.routeLine = null this.terrainLines = 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.stopMarkers.addTo(map) this.terrainLines.addTo(map) // Draw Maidenhead grid overlay this.drawGridOverlay() // Click to add stop map.on("click", (e) => { this.pushEvent("add_stop", { lat: e.latlng.lat, lon: e.latlng.lng }) }) // Handle server events this.handleEvent("stations_updated", ({ stations }) => { this.stations = stations this.renderStations() }) this.handleEvent("stops_updated", ({ stops }) => { this.stops = stops this.renderStops() }) this.handleEvent("stop_terrain", ({ index, reachable }) => { this.renderTerrainLines(index, reachable) }) }, 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 }) marker.bindTooltip(s.label, { permanent: true, direction: "top", offset: [0, -10], className: "station-label" }) this.stationMarkers.addLayer(marker) } this.fitBounds() }, renderStops() { this.stopMarkers.clearLayers() this.terrainLines.clearLayers() if (this.routeLine) { this.map.removeLayer(this.routeLine) this.routeLine = null } const routeCoords = [] for (const stop of this.stops) { routeCoords.push([stop.lat, stop.lon]) const color = stop.score >= 65 ? "#00ffa3" : stop.score >= 50 ? "#ffe566" : stop.score >= 33 ? "#ff9044" : "#ff4f4f" const marker = L.circleMarker([stop.lat, stop.lon], { radius: 10, color: "#fff", weight: 3, fillColor: color, fillOpacity: 0.9, interactive: true }) // Number label const icon = L.divIcon({ html: `
${stop.index + 1}
`, className: "", iconSize: [22, 22], iconAnchor: [11, 11] }) const iconMarker = L.marker([stop.lat, stop.lon], { icon, interactive: false }) iconMarker.bindTooltip(`${stop.grid} — Score: ${stop.score || "?"}, ${stop.reachable_count} workable`, { direction: "top", offset: [0, -14] }) this.stopMarkers.addLayer(marker) this.stopMarkers.addLayer(iconMarker) } // Route line if (routeCoords.length >= 2) { this.routeLine = L.polyline(routeCoords, { color: "#fff", weight: 3, opacity: 0.7, dashArray: "8 6" }).addTo(this.map) } }, renderTerrainLines(stopIndex, reachable) { const stop = this.stops[stopIndex] if (!stop) return for (const r of reachable) { const color = r.workable ? "#00ffa3" : r.verdict === "BLOCKED" ? "#ff4f4f" : "#ffe566" const line = L.polyline([[stop.lat, stop.lon], [r.lat, r.lon]], { color, weight: 1.5, opacity: 0.5, dashArray: r.workable ? null : "4 4" }) line.bindTooltip(`${r.label}: ${r.dist_km} km, ${r.verdict || "?"}${r.diffraction_db > 0 ? ` (${r.diffraction_db} dB)` : ""}`) this.terrainLines.addLayer(line) } }, drawGridOverlay() { const gridLayer = L.layerGroup() // Draw 4-char Maidenhead grid lines (2° lon x 1° lat) for (let lat = 25; lat <= 50; lat += 1) { L.polyline([[lat, -125], [lat, -66]], { color: "#888", weight: 0.5, opacity: 0.3 }).addTo(gridLayer) } for (let lon = -125; lon <= -66; lon += 2) { L.polyline([[25, lon], [50, lon]], { color: "#888", weight: 0.5, opacity: 0.3 }).addTo(gridLayer) } gridLayer.addTo(this.map) }, fitBounds() { const allPoints = [ ...this.stations.map(s => [s.lat, s.lon]), ...this.stops.map(s => [s.lat, s.lon]) ] if (allPoints.length >= 2) { this.map.fitBounds(allPoints, { padding: [40, 40], maxZoom: 10 }) } else if (allPoints.length === 1) { this.map.setView(allPoints[0], 8) } } }