From 3321e9f34ef668f5be7fdb65d34847e685cb300f Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 10 Jul 2026 11:44:54 +0300 Subject: [PATCH 1/7] Fire a signal when the preview panel height has settled. We will use it to jump to the highlighted feature once we know the available area of the map that is not covered by the panel --- app/qml/form/MMPreviewDrawer.qml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/qml/form/MMPreviewDrawer.qml b/app/qml/form/MMPreviewDrawer.qml index 5c0891313..789a27932 100644 --- a/app/qml/form/MMPreviewDrawer.qml +++ b/app/qml/form/MMPreviewDrawer.qml @@ -35,6 +35,8 @@ Item { signal selectMoreClicked( var feature ) signal stakeoutClicked( var feature ) signal closeClicked() + // this signal fires once after content-layout has settled when the feature changes + signal doneLoading() MouseArea { anchors.fill: parent @@ -271,6 +273,23 @@ Item { } } + Timer { + id: heightDebounceTimer + interval: 100 + onTriggered: root.doneLoading() + } + + onImplicitHeightChanged: { + heightDebounceTimer.restart() + } + + Connections { + target: controller + function onFeatureLayerPairChanged() { + heightDebounceTimer.start() + } + } + QtObject { id: internal From 436777a17da6190c8aad42500a3118ab193ae3db Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 10 Jul 2026 11:51:26 +0300 Subject: [PATCH 2/7] - Use the new doneLoading() signal - Use the renamed previewPanelHeight property instead of signal params --- app/qml/form/MMFormController.qml | 19 +++++-------------- app/qml/form/MMFormStackController.qml | 10 +++++----- app/qml/main.qml | 4 ++-- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/app/qml/form/MMFormController.qml b/app/qml/form/MMFormController.qml index 6b098ac52..56ea6a3f9 100644 --- a/app/qml/form/MMFormController.qml +++ b/app/qml/form/MMFormController.qml @@ -37,7 +37,7 @@ Item { property bool layerIsReadOnly: featureLayerPair?.layer?.readOnly ?? false property bool layerIsSpatial: featureLayerPair ? __inputUtils.isSpatialLayer( featureLayerPair.layer ) : false - property real drawerHeight: drawer.height + property real previewPanelHeight: previewPanel.implicitHeight signal closed() signal saveRequested() @@ -46,7 +46,7 @@ Item { signal createLinkedFeature( var targetLayer, var parentPair ) signal multiSelectFeature( var feature ) signal stakeoutFeature( var feature ) - signal previewPanelChanged( var panelHeight ) + signal previewPanelChanged() function openDrawer() { root.panelState = "form" @@ -142,11 +142,6 @@ Item { edge: Qt.BottomEdge closePolicy: Popup.CloseOnEscape // prevents the drawer closing while moving canvas - onOpened: { - if ( panelState === "preview" ) - previewPanelChanged( previewPanel.implicitHeight ) - } - onClosed: { if ( statesManager.state !== "hidden" ) statesManager.state = "closed" @@ -179,8 +174,9 @@ Item { onCloseClicked: drawer.close() - onImplicitHeightChanged: { - previewPanelChanged( previewPanel.implicitHeight ) + onDoneLoading: { + if ( root.panelState === "preview" ) + previewPanelChanged() } } @@ -243,9 +239,4 @@ Item { } } } - - onFeatureLayerPairChanged: { - if ( panelState === "preview" ) - previewPanelChanged( previewPanel.implicitHeight ) - } } diff --git a/app/qml/form/MMFormStackController.qml b/app/qml/form/MMFormStackController.qml index 134b796e1..384079e0a 100644 --- a/app/qml/form/MMFormStackController.qml +++ b/app/qml/form/MMFormStackController.qml @@ -39,8 +39,8 @@ Item { return root.height // form is the highest always } else if ( form.panelState === "preview" ) { - if ( form.drawerHeight > maxHeight ) { - maxHeight = form.drawerHeight + if ( form.previewPanelHeight > maxHeight ) { + maxHeight = form.previewPanelHeight } } } @@ -54,7 +54,7 @@ Item { signal createLinkedFeatureRequested( var targetLayer, var parentPair ) signal multiSelectFeature( var feature ) signal stakeoutFeature( var feature ) - signal previewPanelChanged( var panelHeight ) + signal previewPanelChanged() function openForm( pair, formState, panelState ) { if ( formsStack.depth === 0 ) @@ -308,8 +308,8 @@ Item { formsStack.syncWhenFormCloses = true } - onPreviewPanelChanged: function( panelHeight ) { - root.previewPanelChanged( panelHeight ) + onPreviewPanelChanged: { + root.previewPanelChanged() } onEditGeometry: function( pair ) { diff --git a/app/qml/main.qml b/app/qml/main.qml index f1f75837a..1f11c4fbf 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -860,8 +860,8 @@ ApplicationWindow { closeDrawer() } - onPreviewPanelChanged: function( panelHeight ) { - map.jumpToHighlighted( panelHeight - mapToolbar.height ) + onPreviewPanelChanged: { + map.jumpToHighlighted() } } From a63de4290241593f2390313bbbfb5ad17d589db0 Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 10 Jul 2026 12:03:16 +0300 Subject: [PATCH 3/7] - Store identify click point map coords - Move all highlighting and form opening to a identifyFeature() --- app/qml/main.qml | 13 ++++++------- app/qml/map/MMMapController.qml | 12 +++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/qml/main.qml b/app/qml/main.qml index 1f11c4fbf..4b4957b9b 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -104,8 +104,8 @@ ApplicationWindow { projDialog.open() } - function identifyFeature( pair ) { - let hasNullGeometry = pair.feature.geometry.isNull + function identifyFeature( pair, point = Qt.point(NaN, NaN) ) { + map.identifyLocation = point if ( hasNullGeometry ) { formsStackManager.openForm( pair, "readOnly", "form" ) @@ -180,8 +180,8 @@ ApplicationWindow { return 0 } - onFeatureIdentified: function( pair ) { - formsStackManager.openForm( pair, "readOnly", "preview" ); + onFeatureIdentified: function( pair, point ) { + window.identifyFeature( pair, point ) } onFeaturesIdentified: function( pairs ) { @@ -190,6 +190,7 @@ ApplicationWindow { } onNothingIdentified: { + map.hideHighlight() formsStackManager.closeDrawer() } @@ -957,10 +958,8 @@ ApplicationWindow { secondaryText: model.LayerName leftContent: MMIcon { source: model.LayerIcon } onClicked: { - let pair = model.FeaturePair featurePairSelection.close() - map.highlightPair( pair ) - formsStackManager.openForm( pair, "readOnly", "preview" ); + window.identifyFeature( model.FeaturePair ); } } diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index be9f06451..cdd30d3fe 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -46,7 +46,10 @@ Item { property MM.MapSketchingController sketchingController: sketchesLoader.item?.controller ?? null - signal featureIdentified( var pair ) + // Holds the map coordinates of the point the user identified. NaN if identify was triggered from list of features + property point identifyLocation: Qt.point(NaN, NaN) + + signal featureIdentified( var pair, var point ) signal featuresIdentified( var pairs ) signal nothingIdentified() @@ -261,12 +264,11 @@ Item { } else if ( pair.valid ) // root.state === "view" { - root.highlightPair( pair ) - root.featureIdentified( pair ) + let mapPoint = mapCanvas.mapSettings.screenToCoordinate( screenPoint ) + root.featureIdentified( pair, mapPoint ) } else { - root.hideHighlight() root.nothingIdentified() } } @@ -1419,7 +1421,7 @@ Item { __inputUtils.setExtentToFeature( pair, mapCanvas.mapSettings ) } - function jumpToHighlighted( mapOffset ) { + function jumpToHighlighted() { if ( identifyHighlight.geometry.isNull ) return let screenPt = __inputUtils.relevantGeometryCenterToScreenCoordinates( identifyHighlight.geometry, mapCanvas.mapSettings ) From c5e5d4f6dcdf0791341a27e224d912a6c72930d2 Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 10 Jul 2026 12:08:12 +0300 Subject: [PATCH 4/7] Update canvas panning logic when identifying: - Don't pan if whole feature is near visible map center - Center feature to visible map if it fits - Center clicked location to visible map if whole does not fit --- app/inpututils.cpp | 43 ++++++++++++++++++++---------- app/inpututils.h | 11 +++++--- app/qml/map/MMMapController.qml | 12 ++++++--- app/test/testutilsfunctions.cpp | 47 +++++++++++++++++++++------------ app/test/testutilsfunctions.h | 2 +- 5 files changed, 77 insertions(+), 38 deletions(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index b5573eb39..04c14f77b 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -321,31 +321,46 @@ void InputUtils::setExtentToGeom( const QgsGeometry &geom, InputMapSettings *map mapSettings->setExtent( currentExtent ); } -QPointF InputUtils::relevantGeometryCenterToScreenCoordinates( const QgsGeometry &geom, InputMapSettings *mapSettings ) +QPointF InputUtils::whereToPanWhenIdentifying( const QgsGeometry &geom, InputMapSettings *mapSettings, double bottomOffset, const QPointF &identifyLocation ) { - QPointF screenPoint; - QgsPoint target; - if ( !mapSettings || geom.isNull() || !geom.constGet() ) - return screenPoint; + QgsRectangle effectiveExtent( mapSettings->visibleExtent() ); + + // canvas size in logical pixels; bottomOffset is in logical pixels too and + // screenToCoordinate() expects logical pixel coordinates + const double canvasWidth = mapSettings->outputSize().width() / mapSettings->devicePixelRatio(); + const double canvasHeight = mapSettings->outputSize().height() / mapSettings->devicePixelRatio(); + const QPointF bottomPoint( canvasWidth / 2.0, canvasHeight - bottomOffset ); - const QgsRectangle currentExtent = mapSettings->mapSettings().visibleExtent(); + const QgsPoint bottomPointMap = mapSettings->screenToCoordinate( bottomPoint ); - // Cut the geometry to current extent - const QgsGeometry currentExtentAsGeom = QgsGeometry::fromRect( currentExtent ); - const QgsGeometry intersectedGeom = geom.intersection( currentExtentAsGeom ); + effectiveExtent.setYMinimum( bottomPointMap.y() ); - if ( !intersectedGeom.isEmpty() ) + // Now we calculate a safeEffectiveExtent, slightly smaller, so that we don't allow geometries too close to the screen borders + constexpr double EXTENT_BUFFER_SCALE = 0.82; + const QgsRectangle safeEffectiveExtent( effectiveExtent.scaled( EXTENT_BUFFER_SCALE ) ); + + QPointF screenPoint; + QgsPoint target; + if ( safeEffectiveExtent.contains( geom.boundingBox() ) ) { - target = QgsPoint( intersectedGeom.boundingBox().center() ); + // If the whole geometry is visible, don't move map + return { std::numeric_limits::quiet_NaN(), std::numeric_limits::quiet_NaN() }; } - else + else if ( effectiveExtent.width() >= geom.boundingBox().width() && + effectiveExtent.height() >= geom.boundingBox().height() ) { - // The geometry is outside the current viewed extent - setExtentToGeom( geom, mapSettings ); + // if the whole geometry would fit without changing scale, center it target = QgsPoint( geom.boundingBox().center() ); } + else + { + // the geometry is big, let's pan to the point the user clicked on the map + target = QgsPoint( identifyLocation ); + } screenPoint = mapSettings->coordinateToScreen( target ); + screenPoint.ry() += bottomOffset / 2; + return screenPoint; } diff --git a/app/inpututils.h b/app/inpututils.h index 122661a0f..457b7d9bb 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -92,13 +92,18 @@ class InputUtils: public QObject Q_INVOKABLE void setExtentToGeom( const QgsGeometry &geom, InputMapSettings *mapSettings ); /** - * Returns the center point of the \a geom currently displayed on screen + * Returns the screen point where the map should jump to when identifying features. + * If the map should not pan, a QPointF with NaNs is returned. * - * Fall back to \see setExtentToGeom in case we don't find any geometry relevant + * \param geom The geometry identified + * \param mapSettings Current map settings + * \param bottomOffset Pixel size (height) of drawer that may be covering the map canvas, eg preview panel + * \param identifyLocation The map coords of the location the user clicked for identification * * Nota Bene: Assume geometry and map canvas CRS are the same */ - Q_INVOKABLE QPointF relevantGeometryCenterToScreenCoordinates( const QgsGeometry &geom, InputMapSettings *mapSettings ); + Q_INVOKABLE static QPointF whereToPanWhenIdentifying( const QgsGeometry &geom, InputMapSettings *mapSettings, double bottomOffset, const QPointF &identifyLocation ); + // utility functions to extract information from map settings // (in theory this data should be directly available from .MapTransform diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index cdd30d3fe..0778d0dd7 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1424,10 +1424,16 @@ Item { function jumpToHighlighted() { if ( identifyHighlight.geometry.isNull ) return - let screenPt = __inputUtils.relevantGeometryCenterToScreenCoordinates( identifyHighlight.geometry, mapCanvas.mapSettings ) - screenPt.y += mapOffset / 2 - mapCanvas.jumpTo( screenPt ) + if ( !isNaN( root.identifyLocation.x ) && !isNaN( root.identifyLocation.y ) ) + { + // when identifying with a point on the map, we preserve scale and may only pan + let screenPt = __inputUtils.whereToPanWhenIdentifying( identifyHighlight.geometry, mapCanvas.mapSettings, root.mapExtentOffset, root.identifyLocation ) + if ( isNaN( screenPt.x ) || isNaN( screenPt.y ) ) + return + + mapCanvas.jumpTo( screenPt ) + } } function highlightPair( pair ) { diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 7ac5d6302..647a24c6e 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -473,6 +473,36 @@ void TestUtilsFunctions::testStakeoutPathExtent() } } +void TestUtilsFunctions::testWhereToPanWhenIdentifying() +{ + // bottomOffset and the returned screen point are in logical pixels, + // so the result must not depend on the device pixel ratio + const QVector dprs = { 1.0, 2.0 }; + for ( const qreal dpr : dprs ) + { + InputMapSettings ms; + ms.setDestinationCrs( QgsCoordinateReferenceSystem::fromEpsgId( 4326 ) ); + ms.setDevicePixelRatio( dpr ); + ms.setOutputSize( QSize( 400, 620 ) ); + ms.setExtent( QgsRectangle( 0, 0, 40, 62 ) ); // 0.1 map units per logical pixel + + const double bottomOffset = 200; // drawer covers screen y in [420, 620] ~ map y in [0, 20] + + // point hidden under the drawer -> map should pan so that the point + // ends up centered in the unobstructed part of the canvas + const QgsGeometry hiddenPoint = QgsGeometry::fromPointXY( QgsPointXY( 20, 10 ) ); + QPointF pan = mUtils->whereToPanWhenIdentifying( hiddenPoint, &ms, bottomOffset, QPointF( 20, 10 ) ); + COMPARENEAR( pan.x(), 200.0, 1e-4 ); + COMPARENEAR( pan.y(), 620.0, 1e-4 ); + + // point well inside the unobstructed area -> no pan needed + const QgsGeometry visiblePoint = QgsGeometry::fromPointXY( QgsPointXY( 20, 40 ) ); + pan = mUtils->whereToPanWhenIdentifying( visiblePoint, &ms, bottomOffset, QPointF( 20, 40 ) ); + QVERIFY( std::isnan( pan.x() ) ); + QVERIFY( std::isnan( pan.y() ) ); + } +} + void TestUtilsFunctions::testDistanceBetweenGpsAndFeature() { QgsCoordinateReferenceSystem crs = QgsCoordinateReferenceSystem::fromEpsgId( 4326 ); @@ -954,23 +984,6 @@ void TestUtilsFunctions::testFormatAreaInProjectUnit() QVERIFY( area2str == "1.7 ac" ); } -void TestUtilsFunctions::testRelevantGeometryCenterToScreenCoordinates() -{ - InputMapSettings ms; - ms.setDestinationCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); - ms.setOutputSize( QSize( 436, 690 ) ); - QgsGeometry geom = QgsGeometry::fromWkt( "LineString (605540.02427726075984538 5422974.88796170614659786, 618450.11232534842565656 5430064.85434877127408981, 631042.73919192561879754 5418953.71299590915441513, 652418.458746955730021 5431228.87868097703903913)" ); - double epsilon = 0.1; - - // Case when the geometry can fully be contained within the extent - ms.setExtent( QgsRectangle( 595290, 5.35402e+06, 661796, 5.45927e+06 ) ); - QCOMPARE( InputUtils::equals( mUtils->relevantGeometryCenterToScreenCoordinates( geom, &ms ), QPointF( 220.861, 224.065 ), epsilon ), true ); - - // Case when we cut the geometry to current extent - ms.setExtent( QgsRectangle( 599032, 5.40671e+06, 619818, 5.43961e+06 ) ); - QCOMPARE( InputUtils::equals( mUtils->relevantGeometryCenterToScreenCoordinates( geom, &ms ), QPointF( 286.257, 274.5 ), epsilon ), true ); -} - void TestUtilsFunctions::testIsValidEmail() { // valid emails diff --git a/app/test/testutilsfunctions.h b/app/test/testutilsfunctions.h index 09839fecb..3145c2fe1 100644 --- a/app/test/testutilsfunctions.h +++ b/app/test/testutilsfunctions.h @@ -37,6 +37,7 @@ class TestUtilsFunctions: public QObject void resolveTargetDir(); void testExtractPointFromFeature(); void testStakeoutPathExtent(); + void testWhereToPanWhenIdentifying(); void testDistanceBetweenGpsAndFeature(); void testAngleBetweenGpsAndFeature(); void testIsPointLayerFeature(); @@ -49,7 +50,6 @@ class TestUtilsFunctions: public QObject void testParsePositionUpdates(); void testFormatDistanceInProjectUnit(); void testFormatAreaInProjectUnit(); - void testRelevantGeometryCenterToScreenCoordinates(); void testIsValidEmail(); void testSanitizePath(); void testUniqueString(); From c9b8b3d18d9648cab934fdb8f73a443e5068698e Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 10 Jul 2026 12:09:13 +0300 Subject: [PATCH 5/7] Fix handling of empty but not null geometries --- app/qml/main.qml | 5 +++-- app/qml/map/MMMapController.qml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/qml/main.qml b/app/qml/main.qml index 4b4957b9b..0acdea64e 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -107,12 +107,13 @@ ApplicationWindow { function identifyFeature( pair, point = Qt.point(NaN, NaN) ) { map.identifyLocation = point - if ( hasNullGeometry ) { + let skipPreview = __inputUtils.isEmptyGeometry( pair.feature.geometry ) + if ( skipPreview ) { formsStackManager.openForm( pair, "readOnly", "form" ) } else if ( pair.valid ) { map.highlightPair( pair ) - formsStackManager.openForm( pair, "readOnly", "preview") + formsStackManager.openForm( pair, "readOnly", "preview" ) } } diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 0778d0dd7..ba4cbed77 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1422,7 +1422,7 @@ Item { } function jumpToHighlighted() { - if ( identifyHighlight.geometry.isNull ) + if ( __inputUtils.isEmptyGeometry( identifyHighlight.geometry ) ) return if ( !isNaN( root.identifyLocation.x ) && !isNaN( root.identifyLocation.y ) ) From 516683f0ea06352ea32d6b5893de22385231ea2d Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 10 Jul 2026 12:14:50 +0300 Subject: [PATCH 6/7] - Add animated zoom to map extent - Add method to calculate the map extent required for a geometry to fit the visible part of the map canvas when covered by a drawer - Use animated zoom when identifying features from a list and not on map --- app/inpututils.cpp | 40 +++++++++++++++++++++++ app/inpututils.h | 14 +++++++++ app/qml/map/MMMapCanvas.qml | 56 +++++++++++++++++++++++++++++++++ app/qml/map/MMMapController.qml | 6 ++++ app/test/testutilsfunctions.cpp | 36 +++++++++++++++++++++ app/test/testutilsfunctions.h | 1 + 6 files changed, 153 insertions(+) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 04c14f77b..732a9d8e4 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -364,6 +364,41 @@ QPointF InputUtils::whereToPanWhenIdentifying( const QgsGeometry &geom, InputMap return screenPoint; } +QgsRectangle InputUtils::drawerCompensatedExtent( const QgsGeometry &geom, InputMapSettings *mapSettings, double bottomOffset ) +{ + const QgsRectangle bbox = geom.boundingBox(); + QgsRectangle currentExtent = mapSettings->mapSettings().visibleExtent(); + + // canvas size in logical pixels; the bottom bottomOffset of the canvas is covered by another + // component (e.g. preview drawer), so we center the geometry in the remaining visible part + const double canvasWidth = mapSettings->outputSize().width() / mapSettings->devicePixelRatio(); + const double canvasHeight = mapSettings->outputSize().height() / mapSettings->devicePixelRatio(); + const double visibleHeight = std::max( canvasHeight - bottomOffset, 1.0 ); + + if ( bbox.isEmpty() ) // Deal with an empty bouding box e.g : a point + { + const QgsPointXY center( bbox.center().x(), bbox.center().y() - bottomOffset / 2.0 * mapSettings->mapUnitsPerPoint() ); + const QgsVector offset = currentExtent.center() - center; + currentExtent -= offset; + } + else + { + QgsRectangle paddedBbox = bbox; + + // Add a offset to encompass handles etc.. + // This number is based on what feel confortable for the user + constexpr double SCALE_FACTOR = 1.18; + paddedBbox.scale( SCALE_FACTOR ); + + const double mapUnitsPerPoint = std::max( paddedBbox.width() / canvasWidth, paddedBbox.height() / visibleHeight ); + const double cx = bbox.center().x(); + const double cy = bbox.center().y() - bottomOffset / 2.0 * mapUnitsPerPoint; + currentExtent = QgsRectangle( cx - canvasWidth * mapUnitsPerPoint / 2, cy - canvasHeight * mapUnitsPerPoint / 2, + cx + canvasWidth * mapUnitsPerPoint / 2, cy + canvasHeight * mapUnitsPerPoint / 2 ); + } + return currentExtent; +} + double InputUtils::convertCoordinateString( const QString &rationalValue ) { QStringList values = rationalValue.split( "," ); @@ -2161,6 +2196,11 @@ QString InputUtils::getUniqueString( const QString &newString, const QStringList return uniqueString; } +QgsRectangle InputUtils::extentFromMinMax( double xMin, double yMin, double xMax, double yMax ) +{ + return QgsRectangle( xMin, yMin, xMax, yMax ); +} + bool InputUtils::rescaleImage( const QString &path, QgsProject *activeProject ) { int quality = activeProject->readNumEntry( QStringLiteral( "Mergin" ), QStringLiteral( "PhotoQuality" ), 0 ); diff --git a/app/inpututils.h b/app/inpututils.h index 457b7d9bb..b14e91cd7 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -104,6 +104,17 @@ class InputUtils: public QObject */ Q_INVOKABLE static QPointF whereToPanWhenIdentifying( const QgsGeometry &geom, InputMapSettings *mapSettings, double bottomOffset, const QPointF &identifyLocation ); + /** + * Returns the extent that the map needs to zoom to so that the \a geom fits (with a safe buffer) the visible part + * of the map canvas when the canvas lower part is covered by a drawer of \a bottomOffset pixels high. + * + * \param geom The geometry identified + * \param mapSettings Current map settings + * \param bottomOffset Pixel size (height) of drawer that may be covering the map canvas, eg preview panel + * + * Nota Bene: Assume geometry and map canvas CRS are the same + */ + Q_INVOKABLE static QgsRectangle drawerCompensatedExtent( const QgsGeometry &geom, InputMapSettings *mapSettings, double bottomOffset ); // utility functions to extract information from map settings // (in theory this data should be directly available from .MapTransform @@ -686,6 +697,9 @@ class InputUtils: public QObject */ static QString getUniqueString( const QString &newString, const QStringList &existingStrings ); + //! Trivial constructor for QgsRectangle since the actual constructor is not invokable + Q_INVOKABLE static QgsRectangle extentFromMinMax( double xMin, double yMin, double xMax, double yMax ); + public slots: void onQgsLogMessageReceived( const QString &message, const QString &tag, Qgis::MessageLevel level ); diff --git a/app/qml/map/MMMapCanvas.qml b/app/qml/map/MMMapCanvas.qml index 262a74685..6e29f90ce 100644 --- a/app/qml/map/MMMapCanvas.qml +++ b/app/qml/map/MMMapCanvas.qml @@ -66,6 +66,29 @@ Item { rendererPrivate.unfreeze('jumpTo') } + function jumpToExtent( newExtent ) + { + rendererPrivate.freeze('jumpTo') + + let oldExtent = mapRenderer.mapSettings.visibleExtent + + // Disable animation until initial position is set + jumpExtentAnimator.enabled = false + jumpExtentAnimator.startMinX = oldExtent.xMinimum + jumpExtentAnimator.startMinY = oldExtent.yMinimum + jumpExtentAnimator.startMaxX = oldExtent.xMaximum + jumpExtentAnimator.startMaxY = oldExtent.yMaximum + jumpExtentAnimator.endMinX = newExtent.xMinimum + jumpExtentAnimator.endMinY = newExtent.yMinimum + jumpExtentAnimator.endMaxX = newExtent.xMaximum + jumpExtentAnimator.endMaxY = newExtent.yMaximum + + jumpExtentAnimator.percentage = 0 + jumpExtentAnimator.enabled = true + jumpExtentAnimator.percentage = 100 + rendererPrivate.unfreeze('jumpTo') + } + function zoom( center, scale ) { mapRenderer.zoom( center, scale ) @@ -105,6 +128,39 @@ Item { } } + Item { + id: jumpExtentAnimator + + property double startMinX + property double startMinY + property double startMaxX + property double startMaxY + property double endMinX + property double endMinY + property double endMaxX + property double endMaxY + property double percentage: 0 + + Behavior on percentage { + NumberAnimation { + easing.type: Easing.OutQuart + duration: 500 + } + enabled: jumpExtentAnimator.enabled + } + + onPercentageChanged: { + if ( enabled ) { + let tmpMinX = startMinX - percentage * 0.01 * ( startMinX - endMinX ) + let tmpMinY = startMinY - percentage * 0.01 * ( startMinY - endMinY ) + let tmpMaxX = startMaxX - percentage * 0.01 * ( startMaxX - endMaxX ) + let tmpMaxY = startMaxY - percentage * 0.01 * ( startMaxY - endMaxY ) + + mapRenderer.mapSettings.extent = __inputUtils.extentFromMinMax( tmpMinX, tmpMinY, tmpMaxX, tmpMaxY ) + } + } + } + MM.MapCanvasMap { id: mapRenderer diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index ba4cbed77..2c6839176 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1434,6 +1434,12 @@ Item { mapCanvas.jumpTo( screenPt ) } + else + { + // when identifying by picking from feature list, we zoom to the geometry + let extent = __inputUtils.drawerCompensatedExtent( identifyHighlight.geometry, mapCanvas.mapSettings, root.mapExtentOffset ) + mapCanvas.jumpToExtent( extent ) + } } function highlightPair( pair ) { diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 647a24c6e..84bb49e1b 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -503,6 +503,42 @@ void TestUtilsFunctions::testWhereToPanWhenIdentifying() } } +void TestUtilsFunctions::testDrawerCompensatedExtent() +{ + // bottomOffset is in logical pixels, so the result must not depend on the device pixel ratio + const QVector dprs = { 1.0, 2.0 }; + for ( const qreal dpr : dprs ) + { + InputMapSettings ms; + ms.setDestinationCrs( QgsCoordinateReferenceSystem::fromEpsgId( 4326 ) ); + ms.setDevicePixelRatio( dpr ); + ms.setOutputSize( QSize( 400, 620 ) ); + ms.setExtent( QgsRectangle( 0, 0, 40, 62 ) ); // 0.1 map units per logical pixel + + const double bottomOffset = 200; // drawer covers screen y in [420, 620] + + // point geometry -> scale is kept, center shifts down by bottomOffset / 2 + // in screen space so the point is centered in the unobstructed part + const QgsGeometry point = QgsGeometry::fromPointXY( QgsPointXY( 20, 10 ) ); + QgsRectangle extent = mUtils->drawerCompensatedExtent( point, &ms, bottomOffset ); + COMPARENEAR( extent.width(), 40.0, 1e-4 ); + COMPARENEAR( extent.height(), 62.0, 1e-4 ); + COMPARENEAR( extent.center().x(), 20.0, 1e-4 ); + COMPARENEAR( extent.center().y(), 0.0, 1e-4 ); + + // non-empty bounding box -> zoom so the padded bbox fits the part of the + // canvas not covered by the drawer, centered in it + const QgsGeometry line = QgsGeometry::fromPolylineXY( { QgsPointXY( 10, 10 ), QgsPointXY( 30, 20 ) } ); + extent = mUtils->drawerCompensatedExtent( line, &ms, bottomOffset ); + + // padded bbox is 23.6 x 11.8 -> 0.059 map units per logical pixel to fit 400 x 420 + COMPARENEAR( extent.width(), 400 * 0.059, 1e-4 ); + COMPARENEAR( extent.height(), 620 * 0.059, 1e-4 ); + COMPARENEAR( extent.center().x(), 20.0, 1e-4 ); + COMPARENEAR( extent.center().y(), 15.0 - bottomOffset / 2.0 * 0.059, 1e-4 ); + } +} + void TestUtilsFunctions::testDistanceBetweenGpsAndFeature() { QgsCoordinateReferenceSystem crs = QgsCoordinateReferenceSystem::fromEpsgId( 4326 ); diff --git a/app/test/testutilsfunctions.h b/app/test/testutilsfunctions.h index 3145c2fe1..e55d06abb 100644 --- a/app/test/testutilsfunctions.h +++ b/app/test/testutilsfunctions.h @@ -38,6 +38,7 @@ class TestUtilsFunctions: public QObject void testExtractPointFromFeature(); void testStakeoutPathExtent(); void testWhereToPanWhenIdentifying(); + void testDrawerCompensatedExtent(); void testDistanceBetweenGpsAndFeature(); void testAngleBetweenGpsAndFeature(); void testIsPointLayerFeature(); From 4f45e09cc790b1fe71c628c522d008178132285f Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 10 Jul 2026 12:15:09 +0300 Subject: [PATCH 7/7] Fix test macro --- app/test/testutils.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/test/testutils.h b/app/test/testutils.h index cd906ef4e..e98ebb2d0 100644 --- a/app/test/testutils.h +++ b/app/test/testutils.h @@ -100,7 +100,7 @@ namespace TestUtils #define COMPARENEAR(actual, expected, epsilon) \ do {\ - if (!QTest::compare_helper((qAbs(actual - expected) <= epsilon), \ + if (!QTest::compare_helper((qAbs((actual) - (expected)) <= (epsilon)), \ QString{"Compared values are not the same in respect to epsilon %1"} \ .arg(epsilon).toLocal8Bit().constData(), \ QTest::toString(actual), \