From c5e76a989866eca8230a99268b9a7af1b9b72281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20P=C3=A9rez=20Alonso?= Date: Fri, 17 Jul 2026 16:57:54 +0200 Subject: [PATCH 1/6] fix(PlanView): make new mission items draggable Newly inserted simple mission items, such as waypoints created with the plus button shown on mission legs, could become current before their asynchronous map indicator finished loading, leaving their drag area unusable until the item was selected again. Drag areas now wait for their indicator to load, making new items immediately draggable. --- src/PlanView/MissionItemMapVisualBase.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PlanView/MissionItemMapVisualBase.qml b/src/PlanView/MissionItemMapVisualBase.qml index 8bdc65769dec..f5a1055c95bf 100644 --- a/src/PlanView/MissionItemMapVisualBase.qml +++ b/src/PlanView/MissionItemMapVisualBase.qml @@ -48,7 +48,7 @@ Item { } function updateDragArea() { - if (_missionItem.isCurrentItem && map.planView && _missionItem.specifiesCoordinate) { + if (_missionItem.isCurrentItem && map.planView && _missionItem.specifiesCoordinate && itemVisualLoader.item) { showDragArea() } else { hideDragArea() @@ -108,6 +108,7 @@ Item { if (item) { item.parent = map map.addMapItem(item) + updateDragArea() } } } From 77b02663e50477bc345b3bc025da5f83ab8013aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20P=C3=A9rez=20Alonso?= Date: Thu, 16 Jul 2026 10:35:32 +0200 Subject: [PATCH 2/6] fix(FlightMap): draw current mission item on top When multiple simple mission items shared the same position, later items could partially cover the current target, including its number. The current item is now drawn on top, ensuring the active navigation target can always be identified during flight. --- src/PlanView/SimpleItemMapVisual.qml | 2 +- src/PlanView/TakeoffItemMapVisual.qml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PlanView/SimpleItemMapVisual.qml b/src/PlanView/SimpleItemMapVisual.qml index c441e75cb182..972583a3612c 100644 --- a/src/PlanView/SimpleItemMapVisual.qml +++ b/src/PlanView/SimpleItemMapVisual.qml @@ -65,7 +65,7 @@ MissionItemMapVisualBase { MissionItemIndicator { coordinate: _missionItem.coordinate visible: _missionItem.specifiesCoordinate - z: QGroundControl.zOrderMapItems + z: QGroundControl.zOrderMapItems + (_missionItem.isCurrentItem || _missionItem.hasCurrentChildItem ? 1 : 0) missionItem: _missionItem sequenceNumber: _missionItem.sequenceNumber onClicked: if(_root.interactive) _root.clicked(_missionItem.sequenceNumber) diff --git a/src/PlanView/TakeoffItemMapVisual.qml b/src/PlanView/TakeoffItemMapVisual.qml index 20985a3758a7..a67ffb0690c3 100644 --- a/src/PlanView/TakeoffItemMapVisual.qml +++ b/src/PlanView/TakeoffItemMapVisual.qml @@ -98,7 +98,7 @@ Item { MissionItemIndicator { coordinate: _missionItem.specifiesCoordinate ? _missionItem.coordinate : _missionItem.launchCoordinate - z: QGroundControl.zOrderMapItems + z: QGroundControl.zOrderMapItems + (_missionItem.isCurrentItem || _missionItem.hasCurrentChildItem ? 1 : 0) missionItem: _missionItem sequenceNumber: _missionItem.sequenceNumber onClicked: _root.clicked(_missionItem.sequenceNumber) From ec0f089bd9f735ce6f5a89a8ad500bd19234c200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20P=C3=A9rez=20Alonso?= Date: Fri, 17 Jul 2026 02:42:15 +0200 Subject: [PATCH 3/6] fix(FlightMap): hide arrows on short legs Direction arrows could become larger on screen than their underlying mission legs, obscuring rather than clarifying the route. They now hide whenever a leg's projected length is shorter than the 30-pixel width of the arrow canvas. --- src/FlightMap/MapItems/MapLineArrow.qml | 28 +++++++++++++++++++++++- src/FlightMap/MapItems/PlanMapItems.qml | 1 + src/PlanView/PlanView.qml | 1 + src/PlanView/TransectStyleMapVisuals.qml | 4 ++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/FlightMap/MapItems/MapLineArrow.qml b/src/FlightMap/MapItems/MapLineArrow.qml index 3f0957429709..87fa262110b8 100644 --- a/src/FlightMap/MapItems/MapLineArrow.qml +++ b/src/FlightMap/MapItems/MapLineArrow.qml @@ -8,14 +8,18 @@ import QGroundControl.Controls import QGroundControl.FlightMap MapQuickItem { + id: root + + required property FlightMap mapControl + property color arrowColor: "white" property var fromCoord: QtPositioning.coordinate() property var toCoord: QtPositioning.coordinate() property int arrowPosition: 1 ///< 1: first quarter, 2: halfway, 3: last quarter - property var _map: parent property real _arrowSize: 15 property real _arrowHeading: 0 + property real _screenLegLength: 0 function _updateArrowDetails() { if (fromCoord && fromCoord.isValid && toCoord && toCoord.isValid) { @@ -26,16 +30,38 @@ MapQuickItem { coordinate = QtPositioning.coordinate() _arrowHeading = 0 } + _updateScreenLegLength() + } + + function _updateScreenLegLength() { + if (mapControl && fromCoord && fromCoord.isValid && toCoord && toCoord.isValid) { + const fromPoint = mapControl.fromCoordinate(fromCoord, false) + const toPoint = mapControl.fromCoordinate(toCoord, false) + _screenLegLength = Math.hypot(toPoint.x - fromPoint.x, toPoint.y - fromPoint.y) + } else { + _screenLegLength = 0 + } } onFromCoordChanged: _updateArrowDetails() onToCoordChanged: _updateArrowDetails() + onMapControlChanged: _updateScreenLegLength() + + Component.onCompleted: _updateArrowDetails() + + Connections { + target: root.mapControl + + function onCenterChanged() { root._updateScreenLegLength() } + function onZoomLevelChanged() { root._updateScreenLegLength() } + } sourceItem: Canvas { x: -_arrowSize y: 0 width: _arrowSize * 2 height: _arrowSize + visible: root._screenLegLength >= width onPaint: { var ctx = getContext("2d"); diff --git a/src/FlightMap/MapItems/PlanMapItems.qml b/src/FlightMap/MapItems/PlanMapItems.qml index 8a37f7030ca3..2c194497f6eb 100644 --- a/src/FlightMap/MapItems/PlanMapItems.qml +++ b/src/FlightMap/MapItems/PlanMapItems.qml @@ -68,6 +68,7 @@ Item { fromCoord: object ? object.coordinate1 : undefined toCoord: object ? object.coordinate2 : undefined arrowPosition: 3 + mapControl: _root.map z: QGroundControl.zOrderWaypointLines + 1 } } diff --git a/src/PlanView/PlanView.qml b/src/PlanView/PlanView.qml index a8cf0fb6b574..cc56417c18b7 100644 --- a/src/PlanView/PlanView.qml +++ b/src/PlanView/PlanView.qml @@ -336,6 +336,7 @@ Item { fromCoord: object ? object.coordinate1 : undefined toCoord: object ? object.coordinate2 : undefined arrowPosition: 3 + mapControl: editorMap z: QGroundControl.zOrderWaypointLines + 1 } } diff --git a/src/PlanView/TransectStyleMapVisuals.qml b/src/PlanView/TransectStyleMapVisuals.qml index ce6ddf17da54..6bbf3ffb3f28 100644 --- a/src/PlanView/TransectStyleMapVisuals.qml +++ b/src/PlanView/TransectStyleMapVisuals.qml @@ -136,6 +136,7 @@ Item { fromCoord: _transectPoints[_firstTrueTransectIndex] toCoord: _transectPoints[_firstTrueTransectIndex + 1] arrowPosition: 1 + mapControl: _root.map visible: _currentItem && !_vertexDrag opacity: _root.opacity } @@ -148,6 +149,7 @@ Item { fromCoord: _transectPoints[nextTrueTransectIndex] toCoord: _transectPoints[nextTrueTransectIndex + 1] arrowPosition: 1 + mapControl: _root.map visible: _currentItem && _transectCount > 3 && !_vertexDrag opacity: _root.opacity @@ -162,6 +164,7 @@ Item { fromCoord: _transectPoints[_lastTrueTransectIndex - 1] toCoord: _transectPoints[_lastTrueTransectIndex] arrowPosition: 3 + mapControl: _root.map visible: _currentItem && !_vertexDrag opacity: _root.opacity } @@ -174,6 +177,7 @@ Item { fromCoord: _transectPoints[prevTrueTransectIndex - 1] toCoord: _transectPoints[prevTrueTransectIndex] arrowPosition: 13 + mapControl: _root.map visible: _currentItem && _transectCount > 3 && !_vertexDrag opacity: _root.opacity From 2f9b07c5537df28d8a55f93d3e289e55c3aa5a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20P=C3=A9rez=20Alonso?= Date: Thu, 16 Jul 2026 12:27:55 +0200 Subject: [PATCH 4/6] feat(FlightMap): group overlapping mission items Simple mission items can share coordinates or become close enough at the current map scale for their indicators to overlap. Only the topmost indicator could then be reliably identified or selected, particularly on touchscreens. They are now combined into a single group indicator with direct access to every grouped item. Key changes: - Groups items in screen space and updates the groups when the map scale or item positions change. Indicators remain separate while they can still be selected individually. - Shows the current item's normal marker label when the group contains it, or that of the lowest-sequence item otherwise. A new intermediate marker size and an ellipsis below the label distinguish groups from individual items. - Opens a non-modal horizontal selector when a group is selected in Plan or Fly view. The selector grows with its contents up to a viewport-relative limit while keeping every member directly accessible. Command-specific labels preserve their normal abbreviations and include sequence numbers to distinguish repeated items. - Keeps each simple mission item independently loaded. This preserves mission legs, loiter circles, dragging, and other associated map visuals. Complex items do not interact with the grouping system. --- src/FlightMap/CMakeLists.txt | 1 + .../MapItems/MissionItemIndicator.qml | 39 ++- .../MapItems/MissionItemIndicatorGroup.qml | 320 ++++++++++++++++++ src/FlightMap/MapItems/PlanMapItems.qml | 17 +- src/PlanView/MissionItemIndexLabel.qml | 41 ++- src/PlanView/MissionItemMapVisual.qml | 10 +- src/PlanView/MissionItemMapVisualBase.qml | 9 + src/PlanView/PlanView.qml | 15 +- src/PlanView/SimpleItemMapVisual.qml | 15 +- src/PlanView/TakeoffItemMapVisual.qml | 4 +- 10 files changed, 442 insertions(+), 29 deletions(-) create mode 100644 src/FlightMap/MapItems/MissionItemIndicatorGroup.qml diff --git a/src/FlightMap/CMakeLists.txt b/src/FlightMap/CMakeLists.txt index 1e1d2c3be5cc..0fb2dea4f579 100644 --- a/src/FlightMap/CMakeLists.txt +++ b/src/FlightMap/CMakeLists.txt @@ -15,6 +15,7 @@ qt_add_qml_module(FlightMapModule MapItems/MapLineArrow.qml MapItems/MissionItemIndicator.qml MapItems/MissionItemIndicatorDrag.qml + MapItems/MissionItemIndicatorGroup.qml MapItems/MissionLineView.qml MapItems/PlanMapItems.qml MapItems/ProximityRadarMapView.qml diff --git a/src/FlightMap/MapItems/MissionItemIndicator.qml b/src/FlightMap/MapItems/MissionItemIndicator.qml index 76e53fa1220d..c21a9fb55ee2 100644 --- a/src/FlightMap/MapItems/MissionItemIndicator.qml +++ b/src/FlightMap/MapItems/MissionItemIndicator.qml @@ -11,25 +11,50 @@ MapQuickItem { property var missionItem property int sequenceNumber + property MissionItemIndicatorGroup indicatorGroup + property bool indicatorVisible: true + property bool interactive: true + + readonly property bool _isCurrentItem: missionItem ? missionItem.isCurrentItem || missionItem.hasCurrentChildItem : false + readonly property bool _usesAbbreviation: missionItem && missionItem.abbreviation.charAt(0) > 'A' && missionItem.abbreviation.charAt(0) < 'z' + readonly property var _group: indicatorGroup ? indicatorGroup.groupForItem(missionItem) : null + readonly property bool _isGrouped: _group && _group.items.length > 1 + readonly property bool _isGroupRepresentative: !_group || _group.representative === missionItem signal clicked + function activate() { + if (_isGrouped) { + const topLeft = _label.mapToItem(globals.parent, Qt.point(0, 0)) + const bottomRight = _label.mapToItem(globals.parent, Qt.point(_label.width, _label.height)) + const clickRect = Qt.rect(topLeft.x, topLeft.y, + bottomRight.x - topLeft.x, bottomRight.y - topLeft.y) + indicatorGroup.showGroup(missionItem, clickRect) + } else { + clicked() + } + } + anchorPoint.x: sourceItem.anchorPointX anchorPoint.y: sourceItem.anchorPointY + z: QGroundControl.zOrderMapItems + (_isCurrentItem ? 1 : 0) + visible: indicatorVisible && _isGroupRepresentative sourceItem: MissionItemIndexLabel { id: _label - checked: _isCurrentItem - label: missionItem.abbreviation - index: missionItem.abbreviation.charAt(0) > 'A' && missionItem.abbreviation.charAt(0) < 'z' ? -1 : missionItem.sequenceNumber + checked: _item._isCurrentItem + label: _item.missionItem.abbreviation + index: _item._usesAbbreviation ? -1 : _item.missionItem.sequenceNumber + indicatorSubText: _item._isGrouped ? "…" : "" + small: !_item._isGrouped && !_item._isCurrentItem + medium: _item._isGrouped && !_item._isCurrentItem gimbalYaw: missionItem.missionGimbalYaw vehicleYaw: missionItem.missionVehicleYaw - showGimbalYaw: !isNaN(missionItem.missionGimbalYaw) + showGimbalYaw: !_item._isGrouped && !isNaN(_item.missionItem.missionGimbalYaw) highlightSelected: true - onClicked: _item.clicked() + enabled: _item.interactive + onClicked: _item.activate() opacity: _item.opacity - - property bool _isCurrentItem: missionItem ? missionItem.isCurrentItem || missionItem.hasCurrentChildItem : false } } diff --git a/src/FlightMap/MapItems/MissionItemIndicatorGroup.qml b/src/FlightMap/MapItems/MissionItemIndicatorGroup.qml new file mode 100644 index 000000000000..7caf7ca699d6 --- /dev/null +++ b/src/FlightMap/MapItems/MissionItemIndicatorGroup.qml @@ -0,0 +1,320 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +import QGroundControl +import QGroundControl.Controls +import QGroundControl.FlightMap +import QGroundControl.PlanView + +/// Groups mission item indicators which are too close to select individually. +Item { + id: root + + required property FlightMap map + required property QmlObjectListModel missionItems + + readonly property real _smallIndicatorRadius: _oddCeil((ScreenTools.defaultFontPixelHeight * ScreenTools.smallFontPointRatio) / 2) + readonly property real _largeIndicatorRadius: _oddCeil(ScreenTools.defaultFontPixelHeight * 0.66) + readonly property real _mediumIndicatorRadius: _oddCeil(((_smallIndicatorRadius * 2) + _largeIndicatorRadius) / 3) + readonly property real _groupingDistance: _mediumIndicatorRadius + readonly property var _groupingState: { + const state = [] + const count = missionItems ? missionItems.count : 0 + + for (let i = 0; i < count; i++) { + const item = missionItems.get(i) + if (!item || !item.isSimpleItem || (!item.specifiesCoordinate && !item.isTakeoffItem)) { + continue + } + + const coordinate = _coordinateForItem(item) + state.push({ + sequenceNumber: item.sequenceNumber, + coordinateValid: coordinate && coordinate.isValid, + latitude: coordinate && coordinate.isValid ? coordinate.latitude : NaN, + longitude: coordinate && coordinate.isValid ? coordinate.longitude : NaN, + current: item.isCurrentItem || item.hasCurrentChildItem + }) + } + + return state + } + + property var _groupsBySequenceNumber: ({}) + property var _selectionPanel + + signal itemSelected(int sequenceNumber) + + function groupForItem(item) { + return item ? _groupsBySequenceNumber[item.sequenceNumber] : null + } + + function showGroup(item, clickRect) { + const group = groupForItem(item) + if (!group || group.items.length < 2) { + itemSelected(item.sequenceNumber) + return + } + + _closeSelectionPanel() + const panel = selectionPanelComponent.createObject(mainWindow, { + clickRect: clickRect, + groupItems: group.items + }) + if (!panel) { + return + } + + _selectionPanel = panel + panel.open() + } + + function _oddCeil(value) { + const rounded = Math.ceil(value) + return rounded + (rounded % 2 === 0 ? 1 : 0) + } + + function _scheduleRegroup() { + Qt.callLater(_regroup) + } + + function _coordinateForItem(item) { + return item.isTakeoffItem && !item.specifiesCoordinate ? item.launchCoordinate : item.coordinate + } + + function _closeSelectionPanel() { + if (_selectionPanel) { + _selectionPanel.close() + _selectionPanel = null + } + } + + function _regroup() { + _closeSelectionPanel() + + if (!map || !map.mapReady) { + _groupsBySequenceNumber = {} + return + } + + const entries = [] + const count = missionItems ? missionItems.count : 0 + for (let i = 0; i < count; i++) { + const item = missionItems.get(i) + if (!item || !item.isSimpleItem || (!item.specifiesCoordinate && !item.isTakeoffItem)) { + continue + } + + const coordinate = _coordinateForItem(item) + if (!coordinate || !coordinate.isValid) { + continue + } + + const point = map.fromCoordinate(coordinate, false /* clipToViewPort */) + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) { + continue + } + + entries.push({ + item: item, + point: point, + current: item.isCurrentItem || item.hasCurrentChildItem + }) + } + + entries.sort((first, second) => { + if (first.current !== second.current) { + return first.current ? -1 : 1 + } + return first.item.sequenceNumber - second.item.sequenceNumber + }) + + const cells = {} + const groups = [] + for (const entry of entries) { + const cellX = Math.floor(entry.point.x / _groupingDistance) + const cellY = Math.floor(entry.point.y / _groupingDistance) + let closestGroup = null + let closestDistanceSquared = Infinity + + for (let x = cellX - 1; x <= cellX + 1; x++) { + for (let y = cellY - 1; y <= cellY + 1; y++) { + const nearbyGroups = cells[`${x},${y}`] || [] + for (const group of nearbyGroups) { + const deltaX = entry.point.x - group.point.x + const deltaY = entry.point.y - group.point.y + const distanceSquared = (deltaX * deltaX) + (deltaY * deltaY) + if (distanceSquared <= _groupingDistance * _groupingDistance + && (distanceSquared < closestDistanceSquared + || (distanceSquared === closestDistanceSquared + && group.representative.sequenceNumber < closestGroup.representative.sequenceNumber))) { + closestGroup = group + closestDistanceSquared = distanceSquared + } + } + } + } + + if (closestGroup) { + closestGroup.items.push(entry.item) + continue + } + + const group = { + items: [entry.item], + point: entry.point, + representative: entry.item + } + groups.push(group) + + const cellKey = `${cellX},${cellY}` + if (!cells[cellKey]) { + cells[cellKey] = [] + } + cells[cellKey].push(group) + } + + const groupsBySequenceNumber = {} + for (const group of groups) { + group.items.sort((first, second) => first.sequenceNumber - second.sequenceNumber) + for (const item of group.items) { + groupsBySequenceNumber[item.sequenceNumber] = group + } + } + _groupsBySequenceNumber = groupsBySequenceNumber + } + + Component { + id: selectionPanelComponent + + DropPanel { + id: selectionPanel + + modal: false + + required property var groupItems + + sourceComponent: Component { + Item { + implicitWidth: itemListView.width + implicitHeight: itemListView.height + + QGCListView { + id: itemListView + + width: Math.min(Math.max(contentItem.childrenRect.width, _itemExtent), _maxWidth) + height: _itemExtent + orientation: ListView.Horizontal + model: selectionPanel.groupItems + cacheBuffer: width * 2 + reuseItems: true + currentIndex: -1 + + readonly property real _itemExtent: Math.max(ScreenTools.minTouchPixels, ScreenTools.defaultFontPixelHeight * 2.5) + readonly property real _maxWidth: selectionPanel.dropViewPort.width * 0.4 + + function _scrollBy(delta) { + const currentTarget = wheelScrollAnimation.running ? wheelScrollAnimation.to : contentX + const target = Math.max(0, Math.min(currentTarget - delta, Math.max(0, contentWidth - width))) + wheelScrollAnimation.stop() + wheelScrollAnimation.from = contentX + wheelScrollAnimation.to = target + wheelScrollAnimation.start() + } + + delegate: Item { + id: itemDelegate + + width: Math.max(itemListView._itemExtent, itemLabel.width + ScreenTools.defaultFontPixelWidth) + height: itemListView._itemExtent + + required property var modelData + + readonly property bool _usesAbbreviation: modelData.abbreviation.charAt(0) > 'A' && modelData.abbreviation.charAt(0) < 'z' + readonly property string _supplementaryLabel: !_usesAbbreviation ? "" : `${modelData.abbreviation} (${modelData.sequenceNumber})` + + MissionItemIndexLabel { + id: itemLabel + anchors.centerIn: parent + checked: itemDelegate.modelData.isCurrentItem || itemDelegate.modelData.hasCurrentChildItem + label: itemDelegate.modelData.abbreviation + index: itemDelegate._usesAbbreviation ? -1 : itemDelegate.modelData.sequenceNumber + small: false + supplementaryLabel: itemDelegate._supplementaryLabel + } + + MouseArea { + anchors.fill: parent + onClicked: { + selectionPanel.close() + root.itemSelected(itemDelegate.modelData.sequenceNumber) + } + } + } + + NumberAnimation { + id: wheelScrollAnimation + + target: itemListView + property: "contentX" + duration: 120 + easing.type: Easing.OutCubic + } + + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + orientation: Qt.Vertical + target: null + onWheel: event => { + itemListView._scrollBy(event.pixelDelta.y || event.angleDelta.y) + event.accepted = true + } + } + + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + orientation: Qt.Horizontal + target: null + onWheel: event => { + itemListView._scrollBy(event.pixelDelta.x || event.angleDelta.x) + event.accepted = true + } + } + + onMovementStarted: wheelScrollAnimation.stop() + } + } + } + + onClosed: { + if (root._selectionPanel === selectionPanel) { + root._selectionPanel = null + } + destroy() + } + } + } + + Connections { + target: root.map + + function onMapPanStop() { root._scheduleRegroup() } + function onMapReadyChanged() { root._scheduleRegroup() } + function onZoomLevelChanged() { root._scheduleRegroup() } + } + + Connections { + target: root.missionItems + + function onDataChanged() { root._scheduleRegroup() } + } + + on_GroupingStateChanged: _scheduleRegroup() + on_GroupingDistanceChanged: _scheduleRegroup() + onMapChanged: _scheduleRegroup() + onMissionItemsChanged: _scheduleRegroup() + + Component.onCompleted: _scheduleRegroup() + Component.onDestruction: _closeSelectionPanel() +} diff --git a/src/FlightMap/MapItems/PlanMapItems.qml b/src/FlightMap/MapItems/PlanMapItems.qml index 2c194497f6eb..2eea5fa9b94e 100644 --- a/src/FlightMap/MapItems/PlanMapItems.qml +++ b/src/FlightMap/MapItems/PlanMapItems.qml @@ -27,14 +27,25 @@ Item { property string fmode: vehicle.flightMode + MissionItemIndicatorGroup { + id: _missionItemIndicatorGroup + + map: _root._map + missionItems: _root.largeMapView ? _root._missionController.visualItems : null + onItemSelected: (sequenceNumber) => { + _root._guidedController.confirmAction(_root._guidedController.actionSetWaypoint, Math.max(sequenceNumber, 1)) + } + } + // Add the mission item visuals to the map Repeater { model: largeMapView ? _missionController.visualItems : 0 delegate: MissionItemMapVisual { - map: _map - vehicle: _vehicle - onClicked: _guidedController.confirmAction(_guidedController.actionSetWaypoint, Math.max(object.sequenceNumber, 1)) + map: _map + vehicle: _vehicle + indicatorGroup: _missionItemIndicatorGroup + onClicked: _guidedController.confirmAction(_guidedController.actionSetWaypoint, Math.max(object.sequenceNumber, 1)) } } diff --git a/src/PlanView/MissionItemIndexLabel.qml b/src/PlanView/MissionItemIndexLabel.qml index 332838b9b115..8b24f4e384df 100644 --- a/src/PlanView/MissionItemIndexLabel.qml +++ b/src/PlanView/MissionItemIndexLabel.qml @@ -16,6 +16,7 @@ Canvas { property int index: 0 ///< Index to show in the indicator, 0 will show single char label instead, -1 first char of label in indicator full label to the side property bool checked: false property bool small: !checked + property bool medium: false property bool child: false property bool highlightSelected: false property var color: checked ? "green" : (child ? qgcPal.mapIndicatorChild : qgcPal.mapIndicator) @@ -26,20 +27,21 @@ Canvas { property real vehicleYaw property bool showGimbalYaw: false property bool showSequenceNumbers: true + property string indicatorSubText + property string supplementaryLabel property real _width: showGimbalYaw ? Math.max(_gimbalYawWidth, labelControl.visible ? labelControl.width : indicator.width) : (labelControl.visible ? labelControl.width : indicator.width) property real _height: showGimbalYaw ? _gimbalYawWidth : (labelControl.visible ? labelControl.height : indicator.height) property real _gimbalYawRadius: ScreenTools.defaultFontPixelHeight property real _gimbalYawWidth: _gimbalYawRadius * 2 - property real _smallRadiusRaw: Math.ceil((ScreenTools.defaultFontPixelHeight * ScreenTools.smallFontPointRatio) / 2) - property real _smallRadius: _smallRadiusRaw + ((_smallRadiusRaw % 2 == 0) ? 1 : 0) // odd number for better centering - property real _normalRadiusRaw: Math.ceil(ScreenTools.defaultFontPixelHeight * 0.66) - property real _normalRadius: _normalRadiusRaw + ((_normalRadiusRaw % 2 == 0) ? 1 : 0) - property real _indicatorRadius: small ? _smallRadius : _normalRadius + property real _smallRadius: _oddCeil((ScreenTools.defaultFontPixelHeight * ScreenTools.smallFontPointRatio) / 2) + property real _largeRadius: _oddCeil(ScreenTools.defaultFontPixelHeight * 0.66) + property real _mediumRadius: _oddCeil(((_smallRadius * 2) + _largeRadius) / 3) + property real _indicatorRadius: small ? _smallRadius : (medium ? _mediumRadius : _largeRadius) property real _gimbalRadians: degreesToRadians(vehicleYaw + gimbalYaw - 90) property real _labelMargin: 2 property real _labelRadius: _indicatorRadius + _labelMargin - property string _label: label.length > 1 ? label : "" + property string _label: supplementaryLabel !== "" ? supplementaryLabel : (label.length > 1 ? label : "") property string _index: index === 0 || index === -1 ? label.charAt(0) : (showSequenceNumbers ? index : "") onColorChanged: requestPaint() @@ -49,6 +51,11 @@ Canvas { QGCPalette { id: qgcPal } + function _oddCeil(value) { + const rounded = Math.ceil(value) + return rounded + (rounded % 2 === 0 ? 1 : 0) + } + function degreesToRadians(degrees) { return (Math.PI/180)*degrees } @@ -110,6 +117,7 @@ Canvas { radius: _indicatorRadius QGCLabel { + id: indexLabel anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -118,6 +126,23 @@ Canvas { fontSizeMode: Text.Fit text: _index } + + QGCLabel { + anchors { + horizontalCenter: indexLabel.horizontalCenter + baseline: indexLabel.baseline + baselineOffset: indexLabel.contentHeight / 5 + } + width: indicator.width + height: indicator.height * 0.4 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: "white" + font.pointSize: ScreenTools.smallFontPointSize + fontSizeMode: Text.Fit + text: root.indicatorSubText + visible: text !== "" + } } // Extra circle to indicate selection @@ -132,10 +157,10 @@ Canvas { anchors.centerIn: indicator } - // The mouse click area is always the size of a normal indicator + // The mouse click area is always the size of a large indicator Item { id: mouseAreaFill - anchors.margins: small ? -(_normalRadius - _smallRadius) : 0 + anchors.margins: -(root._largeRadius - root._indicatorRadius) anchors.fill: indicator } diff --git a/src/PlanView/MissionItemMapVisual.qml b/src/PlanView/MissionItemMapVisual.qml index b71a2d950367..7fe04aa331d0 100644 --- a/src/PlanView/MissionItemMapVisual.qml +++ b/src/PlanView/MissionItemMapVisual.qml @@ -5,6 +5,7 @@ import QtPositioning import QGroundControl import QGroundControl.Controls +import QGroundControl.FlightMap /// Mission item map visual Item { @@ -13,6 +14,7 @@ Item { property var map ///< Map control to place item in property var vehicle ///< Vehicle associated with this item property bool interactive: true ///< Vehicle associated with this item + property MissionItemIndicatorGroup indicatorGroup signal clicked(int sequenceNumber) @@ -22,12 +24,16 @@ Item { asynchronous: true Component.onCompleted: { - mapVisualLoader.setSource(object.mapVisualQML, { + const properties = { map: _root.map, vehicle: _root.vehicle, opacity: Qt.binding(() => _root.opacity), interactive: Qt.binding(() => _root.interactive) - }) + } + if (object.isSimpleItem) { + properties.indicatorGroup = _root.indicatorGroup + } + mapVisualLoader.setSource(object.mapVisualQML, properties) } onLoaded: { diff --git a/src/PlanView/MissionItemMapVisualBase.qml b/src/PlanView/MissionItemMapVisualBase.qml index f5a1055c95bf..14d044f43917 100644 --- a/src/PlanView/MissionItemMapVisualBase.qml +++ b/src/PlanView/MissionItemMapVisualBase.qml @@ -15,6 +15,7 @@ Item { property var map ///< Map control to place item in property var vehicle ///< Vehicle associated with this item property bool interactive: true + property MissionItemIndicatorGroup indicatorGroup /// Subclasses must set this to their indicator Component property Component indicatorComponent @@ -122,6 +123,14 @@ Item { itemIndicator: itemVisualLoader.item itemCoordinate: _missionItem.coordinate visible: control.interactive + onClicked: { + const indicator = itemVisualLoader.item + if (indicator && indicator.activate) { + indicator.activate() + } else { + control.clicked(control._missionItem.sequenceNumber) + } + } onItemCoordinateChanged: _missionItem.coordinate = itemCoordinate } } diff --git a/src/PlanView/PlanView.qml b/src/PlanView/PlanView.qml index cc56417c18b7..60236b2f9fc7 100644 --- a/src/PlanView/PlanView.qml +++ b/src/PlanView/PlanView.qml @@ -309,6 +309,16 @@ Item { } } + MissionItemIndicatorGroup { + id: _missionItemIndicatorGroup + + map: editorMap + missionItems: _root._missionController.visualItems + onItemSelected: (sequenceNumber) => { + _root._missionController.setCurrentPlanViewSeqNum(sequenceNumber, false) + } + } + // Add the mission item visuals to the map Repeater { model: _missionController.visualItems @@ -317,7 +327,10 @@ Item { opacity: _editingLayer == _layerMission ? 1 : editorMap._nonInteractiveOpacity interactive: _editingLayer == _layerMission vehicle: _planMasterController.controllerVehicle - onClicked: (sequenceNumber) => { _missionController.setCurrentPlanViewSeqNum(sequenceNumber, false) } + indicatorGroup: _missionItemIndicatorGroup + onClicked: (sequenceNumber) => { + _root._missionController.setCurrentPlanViewSeqNum(sequenceNumber, false) + } } } diff --git a/src/PlanView/SimpleItemMapVisual.qml b/src/PlanView/SimpleItemMapVisual.qml index 972583a3612c..e8aaa8a12dea 100644 --- a/src/PlanView/SimpleItemMapVisual.qml +++ b/src/PlanView/SimpleItemMapVisual.qml @@ -63,13 +63,14 @@ MissionItemMapVisualBase { id: indicatorComponent MissionItemIndicator { - coordinate: _missionItem.coordinate - visible: _missionItem.specifiesCoordinate - z: QGroundControl.zOrderMapItems + (_missionItem.isCurrentItem || _missionItem.hasCurrentChildItem ? 1 : 0) - missionItem: _missionItem - sequenceNumber: _missionItem.sequenceNumber - onClicked: if(_root.interactive) _root.clicked(_missionItem.sequenceNumber) - opacity: _root.opacity + coordinate: _missionItem.coordinate + indicatorGroup: _root.indicatorGroup + indicatorVisible: _missionItem.specifiesCoordinate + interactive: _root.interactive + missionItem: _missionItem + sequenceNumber: _missionItem.sequenceNumber + onClicked: _root.clicked(_missionItem.sequenceNumber) + opacity: _root.opacity } } diff --git a/src/PlanView/TakeoffItemMapVisual.qml b/src/PlanView/TakeoffItemMapVisual.qml index a67ffb0690c3..1bc99f58fc6d 100644 --- a/src/PlanView/TakeoffItemMapVisual.qml +++ b/src/PlanView/TakeoffItemMapVisual.qml @@ -14,6 +14,7 @@ Item { property var map ///< Map control to place item in property var vehicle ///< Vehicle associated with this item property bool interactive: true + property MissionItemIndicatorGroup indicatorGroup property var _missionItem: object property var _takeoffIndicatorItem @@ -98,7 +99,8 @@ Item { MissionItemIndicator { coordinate: _missionItem.specifiesCoordinate ? _missionItem.coordinate : _missionItem.launchCoordinate - z: QGroundControl.zOrderMapItems + (_missionItem.isCurrentItem || _missionItem.hasCurrentChildItem ? 1 : 0) + indicatorGroup: _root.indicatorGroup + interactive: _root.interactive missionItem: _missionItem sequenceNumber: _missionItem.sequenceNumber onClicked: _root.clicked(_missionItem.sequenceNumber) From 0e9e48e7afb8497b91012efb87447d19d2d48844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20P=C3=A9rez=20Alonso?= Date: Thu, 16 Jul 2026 16:53:27 +0200 Subject: [PATCH 5/6] fix(FlightMap): keep items visible at all zooms MapQuickItem's default auto-fade hides simple mission indicators at low zoom levels, removing mission context when several items occupy a small area. Grouped indicators manage that density without losing access to any item. Automatic fading is therefore disabled, allowing simple mission markers to remain visible and selectable at every zoom level. --- src/FlightMap/MapItems/MissionItemIndicator.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FlightMap/MapItems/MissionItemIndicator.qml b/src/FlightMap/MapItems/MissionItemIndicator.qml index c21a9fb55ee2..44758dd0e4c5 100644 --- a/src/FlightMap/MapItems/MissionItemIndicator.qml +++ b/src/FlightMap/MapItems/MissionItemIndicator.qml @@ -37,6 +37,7 @@ MapQuickItem { anchorPoint.x: sourceItem.anchorPointX anchorPoint.y: sourceItem.anchorPointY + autoFadeIn: false z: QGroundControl.zOrderMapItems + (_isCurrentItem ? 1 : 0) visible: indicatorVisible && _isGroupRepresentative From 45833cb84654be8969820ccc02775aee233ed642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20P=C3=A9rez=20Alonso?= Date: Fri, 17 Jul 2026 17:26:41 +0200 Subject: [PATCH 6/6] fix(PlanView): hide split handles on short legs The split handle remained visible on legs whose midpoint was too close to either endpoint for an inserted item to remain separately selectable. It now hides whenever the resulting item would join the same indicator group. --- .../MapItems/MissionItemIndicatorGroup.qml | 11 +++++----- src/PlanView/PlanView.qml | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/FlightMap/MapItems/MissionItemIndicatorGroup.qml b/src/FlightMap/MapItems/MissionItemIndicatorGroup.qml index 7caf7ca699d6..5762181e93d3 100644 --- a/src/FlightMap/MapItems/MissionItemIndicatorGroup.qml +++ b/src/FlightMap/MapItems/MissionItemIndicatorGroup.qml @@ -14,10 +14,11 @@ Item { required property FlightMap map required property QmlObjectListModel missionItems + readonly property real groupingDistance: _mediumIndicatorRadius + readonly property real _smallIndicatorRadius: _oddCeil((ScreenTools.defaultFontPixelHeight * ScreenTools.smallFontPointRatio) / 2) readonly property real _largeIndicatorRadius: _oddCeil(ScreenTools.defaultFontPixelHeight * 0.66) readonly property real _mediumIndicatorRadius: _oddCeil(((_smallIndicatorRadius * 2) + _largeIndicatorRadius) / 3) - readonly property real _groupingDistance: _mediumIndicatorRadius readonly property var _groupingState: { const state = [] const count = missionItems ? missionItems.count : 0 @@ -133,8 +134,8 @@ Item { const cells = {} const groups = [] for (const entry of entries) { - const cellX = Math.floor(entry.point.x / _groupingDistance) - const cellY = Math.floor(entry.point.y / _groupingDistance) + const cellX = Math.floor(entry.point.x / groupingDistance) + const cellY = Math.floor(entry.point.y / groupingDistance) let closestGroup = null let closestDistanceSquared = Infinity @@ -145,7 +146,7 @@ Item { const deltaX = entry.point.x - group.point.x const deltaY = entry.point.y - group.point.y const distanceSquared = (deltaX * deltaX) + (deltaY * deltaY) - if (distanceSquared <= _groupingDistance * _groupingDistance + if (distanceSquared <= groupingDistance * groupingDistance && (distanceSquared < closestDistanceSquared || (distanceSquared === closestDistanceSquared && group.representative.sequenceNumber < closestGroup.representative.sequenceNumber))) { @@ -311,7 +312,7 @@ Item { } on_GroupingStateChanged: _scheduleRegroup() - on_GroupingDistanceChanged: _scheduleRegroup() + onGroupingDistanceChanged: _scheduleRegroup() onMapChanged: _scheduleRegroup() onMissionItemsChanged: _scheduleRegroup() diff --git a/src/PlanView/PlanView.qml b/src/PlanView/PlanView.qml index 60236b2f9fc7..f0949e7538e6 100644 --- a/src/PlanView/PlanView.qml +++ b/src/PlanView/PlanView.qml @@ -357,10 +357,14 @@ Item { // UI for splitting the current segment MapQuickItem { id: splitSegmentItem + + property real _screenLegLength: 0 + anchorPoint.x: sourceItem.width / 2 anchorPoint.y: sourceItem.height / 2 z: QGroundControl.zOrderWaypointLines + 1 visible: _editingLayer == _layerMission + && _screenLegLength > _missionItemIndicatorGroup.groupingDistance * 2 sourceItem: SplitIndicator { onClicked: _missionController.insertSimpleMissionItem(splitSegmentItem.coordinate, @@ -368,6 +372,17 @@ Item { true /* makeCurrentItem */) } + function _updateScreenLegLength() { + const segment = _root._missionController.splitSegment + if (segment && segment.coordinate1.isValid && segment.coordinate2.isValid) { + const fromPoint = editorMap.fromCoordinate(segment.coordinate1, false /* clipToViewPort */) + const toPoint = editorMap.fromCoordinate(segment.coordinate2, false /* clipToViewPort */) + _screenLegLength = Math.hypot(toPoint.x - fromPoint.x, toPoint.y - fromPoint.y) + } else { + _screenLegLength = 0 + } + } + function _updateSplitCoord() { if (_missionController.splitSegment) { var distance = _missionController.splitSegment.coordinate1.distanceTo(_missionController.splitSegment.coordinate2) @@ -376,6 +391,7 @@ Item { } else { coordinate = QtPositioning.coordinate() } + _updateScreenLegLength() } Connections { @@ -388,6 +404,12 @@ Item { function onCoordinate1Changed() { splitSegmentItem._updateSplitCoord() } function onCoordinate2Changed() { splitSegmentItem._updateSplitCoord() } } + + Connections { + target: editorMap + function onCenterChanged() { splitSegmentItem._updateScreenLegLength() } + function onZoomLevelChanged() { splitSegmentItem._updateScreenLegLength() } + } } // Add the vehicles to the map