feat: 新增任務規劃系統 (M-Formation, Circle, Leader-Follower, Grid Sweep)

- mission_planner: 四種任務模式,Leader-Follower 支援 Bezier 曲線轉彎
- mission_executor: QTimer 驅動的多航點執行器
- command_sender: MAVLink setpoint 發送抽象層
- gui: 地圖任務模式選單 + 框選/路徑標記觸發
- map_layout: 任務模式下拉選單 + 確認路徑按鈕 + route bridge 信號
- verify_waypoints: 靜態航點圖 + 動畫模擬驗證 (Play/Pause/Reset)
This commit is contained in:
2026-03-19 14:20:10 +08:00
parent 76b3b0f40c
commit 837b06dab5
7 changed files with 2296 additions and 986 deletions
+221 -118
View File
@@ -122,24 +122,8 @@ class DroneMap:
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #ddd;
} .selection-button-blue {
width: 100%;
padding: 8px;
background-color: #64B5F6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: background-color 0.2s;
}
.selection-button-blue:hover {
background-color: #42A5F5;
}
.selection-button-blue.active {
background-color: #1976D2;
} .selection-button-blue {
.selection-button-blue {
width: 100%;
padding: 8px;
background-color: #64B5F6;
@@ -175,14 +159,38 @@ class DroneMap:
.selection-button.active {
background-color: #D32F2F;
}
.confirm-route-button {
width: 100%;
padding: 8px;
background-color: #66BB6A;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: background-color 0.2s;
}
.confirm-route-button:hover {
background-color: #4CAF50;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="map-controls">
<button class="selection-button" onclick="clearAllTrajectories()">清除軌跡</button>
<button class="selection-button" id="select-rect-btn" onclick="toggleRectSelection()">框選方形區域</button>
<button class="selection-button" id="select-polygon-btn" onclick="togglePolygonSelection()">多點選擇區域</button>
<div style="border-top: 1px solid #ddd; padding-top: 5px; margin-top: 5px;">
<label style="font-size: 12px; color: #555; font-weight: bold;">任務模式</label>
<select id="mission-mode-select" onchange="onMissionModeChanged(this.value)" style="width: 100%; padding: 6px; border-radius: 4px; border: 1px solid #ccc; font-size: 12px; margin-top: 3px;">
<option value="M_FORMATION">列隊飛行</option>
<option value="CIRCLE_FORMATION">環狀包圍</option>
<option value="LEADER_FOLLOWER">跟隨模式</option>
<option value="GRID_SWEEP">柵狀偵查</option>
</select>
</div>
<button class="confirm-route-button" id="confirm-route-btn" onclick="confirmRoute()" style="display: none;">確認路徑</button>
<button class="selection-button" id="select-polygon-btn" onclick="togglePolygonSelection()">多點選擇區域 (開發中)</button>
</div>
<div class="mission-info-panel">
<div class="selection-buttons">
@@ -229,8 +237,10 @@ class DroneMap:
// 地圖點擊事件
map.on('click', function(e) {
if (selectionMode === 'polygon') {
// 多點選擇模式:添加點
addPolygonPoint(e.latlng.lat, e.latlng.lng);
} else if (selectionMode === 'route') {
// 跟隨模式:添加路徑點
addRoutePoint(e.latlng.lat, e.latlng.lng);
} else if (!selectionMode) {
// 正常模式:發送GPS信號
if (bridge) {
@@ -264,7 +274,6 @@ class DroneMap:
}
if (isDrawing && (selectionMode === 'rect' || selectionMode === 'drones') && drawStartPoint) {
// 更新臨時矩形
if (tempRectangle) {
selectionLayer.removeLayer(tempRectangle);
}
@@ -286,7 +295,6 @@ class DroneMap:
drawStartPoint = null;
}
// 重置拖移狀態
clickStartPos = null;
setTimeout(function() {
mapDragStarted = false;
@@ -309,22 +317,22 @@ class DroneMap:
</div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15] // ★ 箭頭往左上移,使 ID 顯示在右下
iconAnchor: [15, 15]
});
}
function getColorBySocketId(id) {
if (id.startsWith("s0_")) return "#00BFFF"; // 天藍色
if (id.startsWith("s1_")) return "#FFD700"; // 金色
if (id.startsWith("s2_")) return "#FF6969"; // 淺紅色
if (id.startsWith("s3_")) return "#FF69B4"; // 熱粉紅
if (id.startsWith("s4_")) return "#00FA9A"; // 中春綠
if (id.startsWith("s5_")) return "#9370DB"; // 中紫色 (串口)
if (id.startsWith("s6_")) return "#FFA500"; // 橙色
if (id.startsWith("s7_")) return "#20B2AA"; // 淺海綠
if (id.startsWith("s8_")) return "#7CFC00"; // 草綠色
if (id.startsWith("s9_")) return "#FF8C00"; // 深橙色
return "#AAAAAA"; // 灰色 (預設)
if (id.startsWith("s0_")) return "#00BFFF";
if (id.startsWith("s1_")) return "#FFD700";
if (id.startsWith("s2_")) return "#FF6969";
if (id.startsWith("s3_")) return "#FF69B4";
if (id.startsWith("s4_")) return "#00FA9A";
if (id.startsWith("s5_")) return "#9370DB";
if (id.startsWith("s6_")) return "#FFA500";
if (id.startsWith("s7_")) return "#20B2AA";
if (id.startsWith("s8_")) return "#7CFC00";
if (id.startsWith("s9_")) return "#FF8C00";
return "#AAAAAA";
}
function createIdIcon(id) {
@@ -345,7 +353,7 @@ class DroneMap:
text-shadow: 1px 1px 2px rgba(255,255,255,0.8), -1px -1px 2px rgba(255,255,255,0.8);
">${sysid}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 6] // ★ icon 中心 = 經緯度點
iconAnchor: [8, 6]
});
}
@@ -358,25 +366,31 @@ class DroneMap:
var trajectoryGroup = L.layerGroup().addTo(map);
// ================================================================================
// 【新增】任務規劃視覺化變數
// 任務規劃視覺化變數
// ================================================================================
var missionPlanGroup = L.layerGroup().addTo(map); // 任務規劃圖層
var centerMarker = null; // 中心點標記
var targetMarker = null; // 目標點標記
var missionLine = null; // 中心點到目標點的虛線
var centerPosition = null; // 中心點經緯度
var targetPosition = null; // 目標點經緯度
var missionPlanGroup = L.layerGroup().addTo(map);
var centerMarker = null;
var targetMarker = null;
var missionLine = null;
var centerPosition = null;
var targetPosition = null;
// 選擇工具變量
var selectionMode = null; // 'drones', 'rect', 'polygon', null
var selectionMode = null; // 'drones', 'rect', 'polygon', 'route', null
var selectionLayer = L.layerGroup().addTo(map);
var polygonPoints = []; // 多點選擇的點
var polygonMarkers = []; // 多點選擇的標記
var tempRectangle = null; // 臨時矩形
var isDrawing = false; // 是否正在繪製
var drawStartPoint = null; // 繪製起點
var mapDragStarted = false; // 地圖是否正在拖移
var clickStartPos = null; // 記錄點擊開始位置
var polygonPoints = [];
var polygonMarkers = [];
var tempRectangle = null;
var isDrawing = false;
var drawStartPoint = null;
var mapDragStarted = false;
var clickStartPos = null;
// 路徑標記變量 (跟隨模式用)
var routePoints = [];
var routeMarkers = [];
var routePolyline = null;
var routeLayer = L.layerGroup().addTo(map);
// ================================================================================
// 更新任務信息面板
@@ -397,7 +411,6 @@ class DroneMap:
targetElem.textContent = '未設定';
}
// 只有當中心點和目標點都設定時才啟用按鈕
if (centerPosition && targetPosition) {
startBtn.disabled = false;
} else {
@@ -409,7 +422,6 @@ class DroneMap:
// 選擇工具函數
// ================================================================================
function toggleSelectAllDrones() {
// 切換全選/取消全選無人機
if (bridge) {
bridge.toggleSelectAllDrones();
console.log('切換全選無人機');
@@ -429,34 +441,18 @@ class DroneMap:
}
}
function toggleRectSelection() {
clearSelectionMode();
if (selectionMode === 'rect') {
selectionMode = null;
document.getElementById('select-rect-btn').classList.remove('active');
map.dragging.enable();
} else {
selectionMode = 'rect';
document.getElementById('select-rect-btn').classList.add('active');
map.dragging.disable();
}
}
function togglePolygonSelection() {
if (selectionMode === 'polygon') {
// 第二次點擊:完成選擇
if (polygonPoints.length >= 3) {
finishPolygonSelection();
} else {
alert('至少需要3個點來形成區域');
// 清除並重置
clearSelectionMode();
clearPolygonPoints();
selectionMode = null;
document.getElementById('select-polygon-btn').classList.remove('active');
}
} else {
// 第一次點擊:清除上次的點位並啟動模式
clearSelectionMode();
clearPolygonPoints();
selectionMode = 'polygon';
@@ -466,23 +462,25 @@ class DroneMap:
}
function clearSelectionMode() {
// 清除所有按鈕的激活狀態
document.getElementById('select-drones-btn').classList.remove('active');
document.getElementById('select-rect-btn').classList.remove('active');
document.getElementById('select-polygon-btn').classList.remove('active');
// 清除選擇圖層
selectionLayer.clearLayers();
tempRectangle = null;
// 重新啟用地圖拖曳
// 不清除 routeLayer(由 clearRoutePoints 單獨管理)
map.dragging.enable();
// 如果離開 route 模式,重置 selectionMode
if (selectionMode !== 'route') {
selectionMode = null;
}
}
function addPolygonPoint(lat, lng) {
polygonPoints.push([lat, lng]);
// 添加邊界點標記
var marker = L.circleMarker([lat, lng], {
radius: 5,
color: '#FF6B6B',
@@ -491,7 +489,6 @@ class DroneMap:
}).addTo(selectionLayer);
polygonMarkers.push(marker);
// 如果有多個點,繪製連線
if (polygonPoints.length > 1) {
L.polyline(polygonPoints, {
color: '#FF6B6B',
@@ -516,7 +513,6 @@ class DroneMap:
return;
}
// 繪製最終多邊形
var polygon = L.polygon(polygonPoints, {
color: '#FF6B6B',
fillColor: '#FF6B6B',
@@ -524,14 +520,12 @@ class DroneMap:
weight: 2
}).addTo(selectionLayer);
// 發送多邊形數據到Python
if (bridge) {
var pointsStr = JSON.stringify(polygonPoints);
bridge.polygonSelected(pointsStr);
console.log('多邊形選擇完成:', polygonPoints);
}
// 重置狀態
selectionMode = null;
document.getElementById('select-polygon-btn').classList.remove('active');
map.dragging.enable();
@@ -540,14 +534,12 @@ class DroneMap:
function finishRectSelection(bounds) {
var selectedDrones = [];
// 清除臨時矩形
if (tempRectangle) {
selectionLayer.removeLayer(tempRectangle);
tempRectangle = null;
}
if (selectionMode === 'drones') {
// 框選無人機模式:先清除全選
if (bridge) {
bridge.clearAllDroneSelection();
}
@@ -556,17 +548,14 @@ class DroneMap:
var pos = markers[droneId].getLatLng();
if (bounds.contains(pos)) {
selectedDrones.push(droneId);
// 觸發無人機點擊信號
if (bridge) {
bridge.emitDroneClicked(droneId);
}
}
});
console.log('框選無人機:', selectedDrones);
// 不保留選擇框,直接完成
} else if (selectionMode === 'rect') {
// 框選區域模式
var rectPoints = [
[bounds.getNorth(), bounds.getWest()],
[bounds.getNorth(), bounds.getEast()],
@@ -574,7 +563,6 @@ class DroneMap:
[bounds.getSouth(), bounds.getWest()]
];
// 繪製最終矩形
L.rectangle(bounds, {
color: '#FF6B6B',
fillColor: '#FF6B6B',
@@ -582,7 +570,6 @@ class DroneMap:
weight: 2
}).addTo(selectionLayer);
// 添加四個角的標記
rectPoints.forEach(point => {
L.circleMarker(point, {
radius: 5,
@@ -592,7 +579,6 @@ class DroneMap:
}).addTo(selectionLayer);
});
// 發送矩形數據到Python
if (bridge) {
var pointsStr = JSON.stringify(rectPoints);
bridge.rectangleSelected(pointsStr);
@@ -603,8 +589,90 @@ class DroneMap:
// 重置狀態
selectionMode = null;
document.getElementById('select-drones-btn').classList.remove('active');
document.getElementById('select-rect-btn').classList.remove('active');
map.dragging.enable();
// 如果仍在 Grid Sweep 模式,重新進入框選
var currentMode = document.getElementById('mission-mode-select').value;
if (currentMode === 'GRID_SWEEP') {
setTimeout(function() {
selectionMode = 'rect';
map.dragging.disable();
console.log('Grid Sweep: 重新進入框選模式');
}, 500);
}
}
// ================================================================================
// 路徑標記函數 (跟隨模式用)
// ================================================================================
function addRoutePoint(lat, lng) {
routePoints.push([lat, lng]);
var idx = routePoints.length;
// 添加編號標記
var icon = L.divIcon({
className: 'route-point',
html: '<div style="' +
'background-color: #FF5722;' +
'color: white;' +
'width: 22px;' +
'height: 22px;' +
'border-radius: 50%;' +
'display: flex;' +
'align-items: center;' +
'justify-content: center;' +
'font-weight: bold;' +
'font-size: 11px;' +
'border: 2px solid white;' +
'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
'">' + idx + '</div>',
iconSize: [22, 22],
iconAnchor: [11, 11]
});
var marker = L.marker([lat, lng], {
icon: icon,
zIndexOffset: 3000
}).addTo(routeLayer);
routeMarkers.push(marker);
// 更新連線
if (routePolyline) {
routeLayer.removeLayer(routePolyline);
}
if (routePoints.length > 1) {
routePolyline = L.polyline(routePoints, {
color: '#FF5722',
weight: 2.5,
opacity: 0.8,
dashArray: '8, 6'
}).addTo(routeLayer);
}
console.log('添加路徑點 #' + idx + ':', lat, lng);
}
function clearRoutePoints() {
routePoints = [];
routeMarkers = [];
if (routePolyline) {
routeLayer.removeLayer(routePolyline);
routePolyline = null;
}
routeLayer.clearLayers();
}
function confirmRoute() {
if (routePoints.length < 2) {
alert('至少需要 2 個路徑點');
return;
}
if (bridge) {
var pointsStr = JSON.stringify(routePoints);
bridge.routeConfirmed(pointsStr);
console.log('路徑確認:', routePoints.length, '個點');
}
}
// ================================================================================
@@ -631,6 +699,36 @@ class DroneMap:
console.log('暫停任務');
}
}
// ================================================================================
// 任務模式切換
// ================================================================================
function onMissionModeChanged(mode) {
// 先清除選擇狀態
selectionMode = null;
clearSelectionMode();
clearRoutePoints();
// 隱藏確認路徑按鈕
document.getElementById('confirm-route-btn').style.display = 'none';
if (mode === 'GRID_SWEEP') {
// 自動進入框選模式
selectionMode = 'rect';
map.dragging.disable();
console.log('Grid Sweep: 進入框選模式');
} else if (mode === 'LEADER_FOLLOWER') {
// 進入路徑標記模式
selectionMode = 'route';
document.getElementById('confirm-route-btn').style.display = 'block';
console.log('跟隨模式: 進入路徑標記模式,點擊地圖添加路徑點');
}
if (bridge) {
bridge.missionModeChanged(mode);
console.log('任務模式切換:', mode);
}
}
// ================================================================================
function initTrajectory(id) {
@@ -696,7 +794,7 @@ class DroneMap:
})
.on('click', function () {
if (mapDragStarted) {
return; // 如果是拖移地圖,不處理點擊
return;
}
if (bridge) {
bridge.emitDroneClicked(id);
@@ -711,7 +809,7 @@ class DroneMap:
})
.on('click', function() {
if (mapDragStarted) {
return; // 如果是拖移地圖,不處理點擊
return;
}
if (bridge) {
bridge.emitDroneClicked(id);
@@ -722,7 +820,7 @@ class DroneMap:
if (!initialized || id < focusedId) {
focusedId = id;
map.setView([lat, lon], 19); // 第一台無人機:重置並放大到最大
map.setView([lat, lon], 19);
initialized = true;
}
}
@@ -738,18 +836,15 @@ class DroneMap:
}
// ================================================================================
// 【新增】任務規劃視覺化函式
// 任務規劃視覺化函式
// ================================================================================
function drawMissionPlan(centerLat, centerLon, targetLat, targetLon) {
// 清除舊的任務規劃標記
clearMissionPlan();
// 保存中心點和目標點位置
centerPosition = {lat: centerLat, lng: centerLon};
targetPosition = {lat: targetLat, lng: targetLon};
updateMissionInfo();
// 繪製中心點標記 "C"(縮小到 20px)
var centerIcon = L.divIcon({
className: 'mission-center',
html: `<div style="
@@ -775,7 +870,6 @@ class DroneMap:
zIndexOffset: 2000
}).addTo(missionPlanGroup);
// 繪製目標點標記 "★"(星星符號)
var targetIcon = L.divIcon({
className: 'mission-target',
html: `<div style="
@@ -805,19 +899,16 @@ class DroneMap:
}
function clearMissionPlan() {
// 清除中心點標記
if (centerMarker) {
missionPlanGroup.removeLayer(centerMarker);
centerMarker = null;
}
// 清除目標點標記
if (targetMarker) {
missionPlanGroup.removeLayer(targetMarker);
targetMarker = null;
}
// 清除位置資訊
centerPosition = null;
targetPosition = null;
updateMissionInfo();
@@ -854,7 +945,6 @@ class DroneMap:
if not self.map_loaded or not self.pending_map_updates:
return
# 批量執行所有待更新的位置
js_commands = []
for drone_id, (lat, lon, heading) in self.pending_map_updates.items():
js_commands.append(f"updateDrone({lat:.6f}, {lon:.6f}, '{drone_id}', {heading:.1f});")
@@ -863,7 +953,6 @@ class DroneMap:
combined_js = "\n".join(js_commands)
self.map_view.page().runJavaScript(combined_js)
# 清空待更新緩存
self.pending_map_updates.clear()
def clear_trajectories(self):
@@ -877,18 +966,10 @@ class DroneMap:
self.map_view.page().runJavaScript(f"focusOn('{drone_id}');")
# ================================================================================
# 【新增】任務規劃視覺化方法
# 任務規劃視覺化方法
# ================================================================================
def draw_mission_plan(self, center_lat, center_lon, target_lat, target_lon):
"""
在地圖上繪製任務規劃
Args:
center_lat: 中心點緯度
center_lon: 中心點經度
target_lat: 目標點緯度
target_lon: 目標點經度
"""
"""在地圖上繪製任務規劃"""
if self.map_loaded:
js_code = f"drawMissionPlan({center_lat:.6f}, {center_lon:.6f}, {target_lat:.6f}, {target_lon:.6f});"
self.map_view.page().runJavaScript(js_code)
@@ -941,16 +1022,26 @@ class DroneMap:
"""獲取多邊形選擇信號"""
return self.bridge.polygon_selected
def get_mission_mode_changed_signal(self):
"""獲取任務模式切換信號"""
return self.bridge.mission_mode_changed
def get_route_confirmed_signal(self):
"""獲取路徑確認信號"""
return self.bridge.route_confirmed
class MapBridge(QObject):
"""JavaScript 和 Python 之間的橋接類"""
gps_signal = pyqtSignal(float, float) # lat, lon
drone_clicked = pyqtSignal(str) # drone_id
clear_all_drone_selection = pyqtSignal() # clear all drone selection
select_all_drones = pyqtSignal() # select all drones
start_mission_signal = pyqtSignal(float, float, float, float) # center_lat, center_lon, target_lat, target_lon
pause_mission_signal = pyqtSignal() # pause mission
rectangle_selected = pyqtSignal(str) # JSON string of rectangle points
polygon_selected = pyqtSignal(str) # JSON string of polygon points
gps_signal = pyqtSignal(float, float)
drone_clicked = pyqtSignal(str)
clear_all_drone_selection = pyqtSignal()
select_all_drones = pyqtSignal()
start_mission_signal = pyqtSignal(float, float, float, float)
pause_mission_signal = pyqtSignal()
rectangle_selected = pyqtSignal(str)
polygon_selected = pyqtSignal(str)
mission_mode_changed = pyqtSignal(str)
route_confirmed = pyqtSignal(str) # 路徑確認 (JSON 字串)
def __init__(self):
super().__init__()
@@ -999,4 +1090,16 @@ class MapBridge(QObject):
def polygonSelected(self, points_json):
"""供 JavaScript 調用的方法 - 多邊形選擇完成"""
self.polygon_selected.emit(points_json)
print(f"🔷 多邊形區域已選擇: {points_json}")
print(f"🔷 多邊形區域已選擇: {points_json}")
@pyqtSlot(str)
def missionModeChanged(self, mode):
"""供 JavaScript 調用的方法 - 任務模式切換"""
self.mission_mode_changed.emit(mode)
print(f"🔄 任務模式切換: {mode}")
@pyqtSlot(str)
def routeConfirmed(self, points_json):
"""供 JavaScript 調用的方法 - 路徑確認"""
self.route_confirmed.emit(points_json)
print(f"📍 路徑已確認: {points_json}")