diff --git a/src/GUI/gui.py b/src/GUI/gui.py index 25da1f4..5a92c16 100644 --- a/src/GUI/gui.py +++ b/src/GUI/gui.py @@ -1318,6 +1318,7 @@ class ControlStationUI(QMainWindow): """切換 group tab — 只需切換 active group 並刷新 UI""" if index < 0: self.active_group_id = None + self._sync_map_follow_for_active_group() return tab_text = self.group_tab_widget.tabText(index) @@ -1345,6 +1346,13 @@ class ControlStationUI(QMainWindow): group = self._get_active_group() selected = group.selected_drone_ids if group else set() current_group_id = group.group_id if group else None + selection_history = getattr( + self, '_last_map_selection_by_group', {}) + previous_selected = selection_history.get(current_group_id, set()) + newly_selected = set(selected) - set(previous_selected) + if current_group_id is not None: + selection_history[current_group_id] = set(selected) + self._last_map_selection_by_group = selection_history # 左側 drone checkbox for drone_id, panel in self.drones.items(): @@ -1372,6 +1380,23 @@ class ControlStationUI(QMainWindow): panel.update_status() self._sync_map_group_colors() + self._sync_map_follow_for_active_group() + if newly_selected: + # 選取 UAV 時移動到該機目前位置;非任務狀態只移動一次。 + self.drone_map.focus_on_drone(sorted(newly_selected)[0]) + + def _sync_map_follow_for_active_group(self): + """只在 active group 的任務執行中啟用地圖 UAV 跟隨。""" + group = self._get_active_group() + running = bool( + group is not None + and group.executor is not None + and group.executor.state == MissionState.RUNNING + ) + preferred_drone_id = None + if running and group.selected_drone_ids: + preferred_drone_id = sorted(group.selected_drone_ids)[0] + self.drone_map.set_mission_following(running, preferred_drone_id) def _sync_map_group_colors(self): """同步每台 drone 的 group 顏色到地圖箭頭外框。""" @@ -1538,6 +1563,7 @@ class ControlStationUI(QMainWindow): group.executor.hover_stable_sec = ep.get( 'hover_stable_sec', group.executor.hover_stable_sec) group.executor.start(group.planned_waypoints) + self._sync_map_follow_for_active_group() panel = self.group_panels.get(group_id) if panel: panel.update_status() @@ -1554,6 +1580,7 @@ class ControlStationUI(QMainWindow): elif group.executor.state == MissionState.PAUSED: group.executor.resume() self.statusBar().showMessage(f"Group {group_id}: 任務已恢復", 3000) + self._sync_map_follow_for_active_group() panel = self.group_panels.get(group_id) if panel: panel.update_status() @@ -1565,6 +1592,7 @@ class ControlStationUI(QMainWindow): return if group.executor: group.executor.stop() + self._sync_map_follow_for_active_group() group.planned_waypoints = None group.mission_target = None self.drone_map.clear_mission_plan_for_group(group_id) @@ -1917,6 +1945,7 @@ class ControlStationUI(QMainWindow): def _on_group_mission_completed(self, group_id): """群組任務完成回呼""" + self._sync_map_follow_for_active_group() panel = self.group_panels.get(group_id) if panel: panel.update_status() @@ -2257,6 +2286,9 @@ class ControlStationUI(QMainWindow): self.statusBar().showMessage("請先建立任務群組", 3000) return + # 不論後續是否能進行任務規劃,都先記錄最新點擊的目標點。 + group.mission_target = (float(lat), float(lon)) + mode_map = { 'M_FORMATION': MissionType.M_FORMATION, 'CIRCLE_FORMATION': MissionType.CIRCLE_FORMATION, @@ -2265,9 +2297,6 @@ class ControlStationUI(QMainWindow): if mission_type is None: return # Grid Sweep / Leader-Follower 由各自的觸發方式處理 - # XBee GOTO 直接使用該 Group 最近一次點擊的地圖座標,與文字欄位解耦。 - group.mission_target = (float(lat), float(lon)) - selected_drones = self._get_group_drones(group) if len(selected_drones) == 0: self.statusBar().showMessage(f"Group {group.group_id}: 請先分配無人機", 3000) diff --git a/src/GUI/map_layout.py b/src/GUI/map_layout.py index 7485585..56fdbc0 100644 --- a/src/GUI/map_layout.py +++ b/src/GUI/map_layout.py @@ -21,6 +21,8 @@ class DroneMap: self.font_scale = 1.0 self.socket_colors = {} self.drone_group_colors = {} + self.mission_following_enabled = False + self.mission_following_drone_id = None # 創建橋接對象 self.bridge = MapBridge() @@ -199,16 +201,18 @@ class DroneMap: bridge = channel.objects.bridge; }); - var map = L.map('map').setView([0, 0], 19); + var map = L.map('map', {maxZoom: 20}).setView([0, 0], 19); // 創建不同的地圖圖層 var streetLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, + maxNativeZoom: 19, + maxZoom: 20, attribution: '© OpenStreetMap contributors' }); var satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { - maxZoom: 19, + maxNativeZoom: 19, + maxZoom: 20, attribution: 'Tiles © Esri' }); @@ -217,7 +221,6 @@ class DroneMap: currentLayer.addTo(map); var isSatellite = true; - // 地圖點擊事件 map.on('click', function(e) { if (selectionMode === 'polygon') { @@ -349,6 +352,7 @@ class DroneMap: var socketColors = {}; var droneGroupColors = {}; var focusedId = null; + var missionFollowingEnabled = false; var initialized = false; var trajectoryGroup = L.layerGroup().addTo(map); @@ -767,15 +771,34 @@ class DroneMap: function focusOn(id) { if (!markers[id]) return; - focusedId = id; + // 非任務狀態只移動一次;任務執行中才記住焦點持續跟隨。 + focusedId = missionFollowingEnabled ? id : null; var latlng = markers[id].getLatLng(); autoCenteringMap = true; map.flyTo(latlng, map.getZoom()); setTimeout(() => { autoCenteringMap = false; }, 500); } - setInterval(() => { + function setMissionFollowingEnabled(enabled, preferredId) { + missionFollowingEnabled = Boolean(enabled); + if (!missionFollowingEnabled) { + // 非任務狀態不跟隨 UAV 或目標點,保留使用者手動地圖視角。 + focusedId = null; + return; + } + + // 任務已在執行時保留原有焦點;尚無焦點時跟隨指定 UAV。 if (focusedId && markers[focusedId]) { + return; + } + if (preferredId && markers[preferredId]) { + focusOn(preferredId); + initialized = true; + } + } + + setInterval(() => { + if (missionFollowingEnabled && focusedId && markers[focusedId]) { var latlng = markers[focusedId].getLatLng(); autoFollowPanTo(latlng); } @@ -793,7 +816,7 @@ class DroneMap: .setLatLng([lat, lon]) .setRotationAngle(heading); idLabels[id].setLatLng([lat, lon]); - if (id === focusedId) { + if (missionFollowingEnabled && id === focusedId) { autoFollowPanTo(markers[id].getLatLng()); } } else { @@ -813,7 +836,6 @@ class DroneMap: if (bridge) { bridge.emitDroneClicked(id); } - focusOn(id); }) .addTo(map); @@ -828,14 +850,23 @@ class DroneMap: if (bridge) { bridge.emitDroneClicked(id); } - focusOn(id); }) .addTo(map); - if (!initialized || id < focusedId) { - focusedId = id; + if (!initialized) { + // 第一次收到 UAV 位置時只做一次初始置中, + // 非任務狀態不設 focusedId,所以後續不會跟隨。 + if (missionFollowingEnabled) { + focusedId = id; + } + autoCenteringMap = true; map.setView([lat, lon], 19); + setTimeout(() => { autoCenteringMap = false; }, 300); initialized = true; + } else if (missionFollowingEnabled && + (!focusedId || id < focusedId)) { + focusedId = id; + map.setView([lat, lon], 19); } } } @@ -975,6 +1006,9 @@ class DroneMap: self.set_font_scale(self.font_scale) self.set_socket_colors(self.socket_colors) self.set_drone_group_colors(self.drone_group_colors) + self.set_mission_following( + self.mission_following_enabled, + self.mission_following_drone_id) else: _log("ERROR", "地圖載入失敗") @@ -1021,6 +1055,17 @@ class DroneMap: if self.map_loaded: self.map_view.page().runJavaScript(f"focusOn('{drone_id}');") + def set_mission_following(self, enabled, preferred_drone_id=None): + """只在任務執行中啟用 UAV 自動跟隨。""" + self.mission_following_enabled = bool(enabled) + self.mission_following_drone_id = preferred_drone_id + if self.map_loaded: + preferred_json = json.dumps(preferred_drone_id) + self.map_view.page().runJavaScript( + "setMissionFollowingEnabled(" + f"{str(self.mission_following_enabled).lower()}, " + f"{preferred_json});") + def set_font_scale(self, scale): """設定地圖 HTML 控制項的字體倍率。""" self.font_scale = scale @@ -1149,7 +1194,7 @@ class MapBridge(QObject): def emitGpsSignal(self, lat, lon): """供 JavaScript 調用的方法""" self.gps_signal.emit(lat, lon) - + @pyqtSlot(str) def emitDroneClicked(self, drone_id): """供 JavaScript 調用的方法 - 當無人機被點擊時"""