diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2f29ad786..09f9e4099 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -246,7 +246,7 @@ jobs: -S ../ \ -B ./ - - name: build APK + - name: Build APK env: ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} ANDROID_NDK_HOST: darwin-x86_64 @@ -271,7 +271,7 @@ jobs: path: ${{ github.workspace }}/merginmaps-${{ env.INPUT_VERSION_CODE }}.apk name: Mergin Maps ${{ env.INPUT_VERSION_CODE }} APK [v7 + v8a] - - name: build AAB + - name: Build AAB if: ${{ github.ref_name == 'master' || github.ref_type == 'tag' }} env: ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} @@ -298,4 +298,3 @@ jobs: with: path: ${{ github.workspace }}/merginmaps-${{ env.INPUT_VERSION_CODE }}.aab name: Mergin Maps ${{ env.INPUT_VERSION_CODE }} AAB - diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index b96c77c27..cf9201375 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -27,6 +27,7 @@ set(MM_SRCS maptools/abstractmaptool.cpp maptools/recordingmaptool.cpp maptools/splittingmaptool.cpp + maptools/measurementmaptool.cpp ios/iosimagepicker.cpp ios/iosutils.cpp position/providers/abstractpositionprovider.cpp @@ -109,6 +110,7 @@ set(MM_HDRS maptools/abstractmaptool.h maptools/recordingmaptool.h maptools/splittingmaptool.h + maptools/measurementmaptool.h ios/iosimagepicker.h ios/iosutils.h position/providers/abstractpositionprovider.h diff --git a/app/icons/CloseShape.svg b/app/icons/CloseShape.svg new file mode 100644 index 000000000..594a3a78e --- /dev/null +++ b/app/icons/CloseShape.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/icons/Measure.svg b/app/icons/Measure.svg new file mode 100644 index 000000000..0666bf957 --- /dev/null +++ b/app/icons/Measure.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/icons/icons.qrc b/app/icons/icons.qrc index c3f553a97..3f88e0ec6 100644 --- a/app/icons/icons.qrc +++ b/app/icons/icons.qrc @@ -97,5 +97,7 @@ ZoomToProject.svg StakeOut.svg Student.svg + Measure.svg + CloseShape.svg diff --git a/app/images/NeutralMMSymbol.svg b/app/images/NeutralMMSymbol.svg new file mode 100644 index 000000000..e31c406a2 --- /dev/null +++ b/app/images/NeutralMMSymbol.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/images/images.qrc b/app/images/images.qrc index ac0474d90..b0b772a30 100644 --- a/app/images/images.qrc +++ b/app/images/images.qrc @@ -23,6 +23,7 @@ ExternalGpsGreen.svg NegativeMMSymbol.svg PositiveMMSymbol.svg + NeutralMMSymbol.svg CloseAccount.svg Attention.svg Bubble.svg diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 5b40e14c0..218c024e1 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -168,25 +168,48 @@ QString InputUtils::formatNumber( const double number, int precision ) return QString::number( number, 'f', precision ); } -QString InputUtils::formatDistanceInProjectUnit( const double distanceInMeters, int precision, Qgis::DistanceUnit destUnit ) +QString InputUtils::formatDistanceInProjectUnit( const double distanceInMeters, int precision, QgsProject *project ) { - Qgis::DistanceUnit distUnit = destUnit; + if ( !project ) + return QString(); + + return InputUtils::formatDistanceHelper( distanceInMeters, precision, project->distanceUnits() ); +} - if ( distUnit == Qgis::DistanceUnit::Unknown ) +QString InputUtils::formatDistanceHelper( const double distanceInMeters, int precision, Qgis::DistanceUnit destUnit ) +{ + if ( destUnit == Qgis::DistanceUnit::Unknown ) { - distUnit = QgsProject::instance()->distanceUnits(); + destUnit = Qgis::DistanceUnit::Meters; } - if ( distUnit == Qgis::DistanceUnit::Unknown ) + const double factor = QgsUnitTypes::fromUnitToUnitFactor( Qgis::DistanceUnit::Meters, destUnit ); + const double distance = distanceInMeters * factor; + const QString abbreviation = QgsUnitTypes::toAbbreviatedString( destUnit ); + + return QString( "%L1 %2" ).arg( QString::number( distance, 'f', precision ), abbreviation ); +} + +QString InputUtils::formatAreaInProjectUnit( const double areaInSquareMeters, int precision, QgsProject *project ) +{ + if ( !project ) + return QString(); + + return InputUtils::formatAreaHelper( areaInSquareMeters, precision, project->areaUnits() ); +} + +QString InputUtils::formatAreaHelper( const double areaInSquareMeters, int precision, Qgis::AreaUnit destUnit ) +{ + if ( destUnit == Qgis::AreaUnit::Unknown ) { - return QString::number( distanceInMeters, 'f', precision ); + destUnit = Qgis::AreaUnit::SquareMeters; } - double factor = QgsUnitTypes::fromUnitToUnitFactor( Qgis::DistanceUnit::Meters, distUnit ); - double distance = distanceInMeters * factor; - QString abbreviation = QgsUnitTypes::toAbbreviatedString( distUnit ); + const double factor = QgsUnitTypes::fromUnitToUnitFactor( Qgis::AreaUnit::SquareMeters, destUnit ); + const double area = areaInSquareMeters * factor; + const QString abbreviation = QgsUnitTypes::toAbbreviatedString( destUnit ); - return QString( "%1 %2" ).arg( QString::number( distance, 'f', precision ), abbreviation ); + return QString( "%L1 %2" ).arg( QString::number( area, 'f', precision ), abbreviation ); } QString InputUtils::formatDateTimeDiff( const QDateTime &tMin, const QDateTime &tMax ) @@ -2194,3 +2217,8 @@ bool InputUtils::openLink( const QString &homePath, const QString &link ) return true; } + +double InputUtils::pixelDistanceBetween( const QPointF &p1, const QPointF &p2 ) +{ + return std::hypot( p1.x() - p2.x(), p1.y() - p2.y() ); +} diff --git a/app/inpututils.h b/app/inpututils.h index 2ba463900..83be81f39 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -75,7 +75,12 @@ class InputUtils: public QObject Q_INVOKABLE QString getFileName( const QString &filePath ); Q_INVOKABLE QString formatProjectName( const QString &fullProjectName ); Q_INVOKABLE QString formatNumber( const double number, int precision = 1 ); - Q_INVOKABLE QString formatDistanceInProjectUnit( const double distanceInMeters, int precision = 1, Qgis::DistanceUnit destUnit = Qgis::DistanceUnit::Unknown ); + Q_INVOKABLE QString formatDistanceInProjectUnit( const double distanceInMeters, int precision, QgsProject *project ); + Q_INVOKABLE QString formatAreaInProjectUnit( const double areaInSquareMeters, int precision, QgsProject *project ); + + static QString formatDistanceHelper( const double distanceInMeters, int precision, Qgis::DistanceUnit destUnit ); + static QString formatAreaHelper( const double areaInSquareMeters, int precision, Qgis::AreaUnit destUnit ); + Q_INVOKABLE void setExtentToFeature( const FeatureLayerPair &pair, InputMapSettings *mapSettings ); /** @@ -579,6 +584,11 @@ class InputUtils: public QObject */ static QVector qgisProfilerLog(); + /** + * Calculates the Euclidean distance between two pixel points + */ + static double pixelDistanceBetween( const QPointF &p1, const QPointF &p2 ); + public slots: void onQgsLogMessageReceived( const QString &message, const QString &tag, Qgis::MessageLevel level ); diff --git a/app/main.cpp b/app/main.cpp index d0c35cd24..2ff044e83 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -114,6 +114,7 @@ #include "maptools/abstractmaptool.h" #include "maptools/recordingmaptool.h" #include "maptools/splittingmaptool.h" +#include "maptools/measurementmaptool.h" #include "layer/layertreemodel.h" #include "layer/layertreemodelpixmapprovider.h" @@ -348,6 +349,7 @@ void initDeclarative() qmlRegisterUncreatableType< AbstractMapTool >( "mm", 1, 0, "AbstractMapTool", "Instantiate one of child map tools instead" ); qmlRegisterType< RecordingMapTool >( "mm", 1, 0, "RecordingMapTool" ); qmlRegisterType< SplittingMapTool >( "mm", 1, 0, "SplittingMapTool" ); + qmlRegisterType< MeasurementMapTool >( "mm", 1, 0, "MeasurementMapTool" ); } void addQmlImportPath( QQmlEngine &engine ) diff --git a/app/maptools/abstractmaptool.cpp b/app/maptools/abstractmaptool.cpp index dd8b25636..ee81f1dc0 100644 --- a/app/maptools/abstractmaptool.cpp +++ b/app/maptools/abstractmaptool.cpp @@ -26,6 +26,10 @@ void AbstractMapTool::setMapSettings( InputMapSettings *newMapSettings ) { if ( mMapSettings == newMapSettings ) return; + + emit onAboutToChangeMapSettings(); + mMapSettings = newMapSettings; + emit mapSettingsChanged( mMapSettings ); } diff --git a/app/maptools/abstractmaptool.h b/app/maptools/abstractmaptool.h index ec6a9ca99..e6eba3532 100644 --- a/app/maptools/abstractmaptool.h +++ b/app/maptools/abstractmaptool.h @@ -30,11 +30,10 @@ class AbstractMapTool : public QObject void setMapSettings( InputMapSettings *newMapSettings ); signals: - + void onAboutToChangeMapSettings(); void mapSettingsChanged( InputMapSettings *mapSettings ); private: - InputMapSettings *mMapSettings = nullptr; }; diff --git a/app/maptools/measurementmaptool.cpp b/app/maptools/measurementmaptool.cpp new file mode 100644 index 000000000..dcaad908b --- /dev/null +++ b/app/maptools/measurementmaptool.cpp @@ -0,0 +1,319 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "measurementmaptool.h" + +MeasurementMapTool::MeasurementMapTool( QObject *parent ) + : AbstractMapTool{ parent } +{ + connect( this, &AbstractMapTool::onAboutToChangeMapSettings, this, &MeasurementMapTool::resetMapSettings ); + connect( this, &AbstractMapTool::mapSettingsChanged, this, &MeasurementMapTool::updateMapSettings ); +} + +MeasurementMapTool::~MeasurementMapTool() = default; + +void MeasurementMapTool::addPoint() +{ + if ( mapSettings() ) + { + QgsPoint transformedPoint = mapSettings()->screenToCoordinate( mCrosshairPoint ); + + if ( !mPoints.empty() && transformedPoint == mPoints.back() ) + return; + + mPoints.push_back( transformedPoint ); + rebuildGeometry(); + } +} + +void MeasurementMapTool::removePoint() +{ + if ( !mPoints.isEmpty() && mapSettings() ) + { + mPoints.pop_back(); + checkCanCloseShape(); + rebuildGeometry(); + updateDistance(); + } +} + +void MeasurementMapTool::updateDistance() +{ + if ( mPoints.isEmpty() || !mapSettings() ) + { + setLengthWithGuideline( 0.0 ); + return; + } + + checkCanCloseShape(); + + QgsPoint lastPoint = mPoints.last(); + QgsPoint transformedCrosshairPoint = mapSettings()->screenToCoordinate( mCrosshairPoint ); + + double calculatedLength = mPerimeter + mDistanceArea.measureLine( transformedCrosshairPoint, lastPoint ); + setLengthWithGuideline( calculatedLength ); +} + +void MeasurementMapTool::checkCanCloseShape() +{ + if ( !mRecordedGeometry.isEmpty() && mapSettings() && mPoints.count() < 3 ) + { + setCanCloseShape( false ); + return; + } + + QgsPoint firstPoint = mPoints.first(); + QPointF firstPointScreen = mapSettings()->coordinateToScreen( firstPoint ); + double distanceToFirstPoint = InputUtils::pixelDistanceBetween( mCrosshairPoint, firstPointScreen ); + setCanCloseShape( distanceToFirstPoint <= CLOSE_THRESHOLD ); +} + +void MeasurementMapTool::finalizeMeasurement( bool closeShapeClicked ) +{ + if ( mPoints.count() < 2 || !mapSettings() ) + return; + + QList pointList; + for ( const QgsPoint &point : mPoints ) + pointList.append( QgsPointXY( point.x(), point.y() ) ); + + QgsGeometry geometry; + double perimeter = 0.0; + + if ( closeShapeClicked && mCanCloseShape ) + { + geometry = QgsGeometry::fromPolygonXY( QList>() << pointList ); + perimeter = mDistanceArea.measurePerimeter( geometry ); + setArea( mDistanceArea.measureArea( geometry ) ); + setCanCloseShape( false ); + } + else + { + geometry = QgsGeometry::fromPolylineXY( pointList ); + perimeter = mDistanceArea.measureLength( geometry ); + } + + setRecordedGeometry( geometry ); + setPerimeter( perimeter ); + setMeasurementFinalized( true ); +} + +void MeasurementMapTool::resetMeasurement() +{ + mPoints.clear(); + + setPerimeter( 0.0 ); + setArea( 0.0 ); + setLengthWithGuideline( 0.0 ); + setCanCloseShape( false ); + setMeasurementFinalized( false ); + + rebuildGeometry(); +} + +void MeasurementMapTool::rebuildGeometry() +{ + if ( !mapSettings() ) + return; + + QgsGeometry geometry; + + QgsMultiPoint *existingVertices = new QgsMultiPoint(); + mExistingVertices.set( existingVertices ); + + if ( mPoints.count() > 0 ) + { + geometry = QgsGeometry::fromPolyline( mPoints ); + + for ( const QgsPoint &point : mPoints ) + { + existingVertices->addGeometry( point.clone() ); + } + + double perimeter = mDistanceArea.measureLength( geometry ); + setPerimeter( perimeter ); + setCanUndo( true ); + } + else + { + setCanUndo( false ); + } + + // If we have more two or more points, "Done" button will be enabled + bool hasValidGeometry = !mRecordedGeometry.isEmpty() && mPoints.count() >= 2; + setIsValidGeometry( hasValidGeometry ); + + emit existingVerticesChanged( mExistingVertices ); + setRecordedGeometry( geometry ); +} + +void MeasurementMapTool::resetMapSettings() +{ + InputMapSettings *currentMapSettings = mapSettings(); + + if ( currentMapSettings ) + { + disconnect( currentMapSettings ); + } +} + +void MeasurementMapTool::updateMapSettings( InputMapSettings *newMapSettings ) +{ + AbstractMapTool::setMapSettings( newMapSettings ); + + InputMapSettings *updatedMapSettings = mapSettings(); + + if ( updatedMapSettings ) + { + connect( updatedMapSettings, &InputMapSettings::extentChanged, this, &MeasurementMapTool::updateDistance ); + + mDistanceArea.setEllipsoid( updatedMapSettings->project()->ellipsoid() ); + mDistanceArea.setSourceCrs( updatedMapSettings->destinationCrs(), updatedMapSettings->transformContext() ); + } +} + +const QgsGeometry &MeasurementMapTool::recordedGeometry() const +{ + return mRecordedGeometry; +} + +QgsGeometry MeasurementMapTool::existingVertices() const +{ + return mExistingVertices; +} + +void MeasurementMapTool::setExistingVertices( const QgsGeometry &vertices ) +{ + if ( mExistingVertices.equals( vertices ) ) + return; + + mExistingVertices = vertices; + emit existingVerticesChanged( mExistingVertices ); +} + +double MeasurementMapTool::area() const +{ + return mArea; +} + +double MeasurementMapTool::perimeter() const +{ + return mPerimeter; +} + +QPointF MeasurementMapTool::crosshairPoint() const +{ + return mCrosshairPoint; +} + +double MeasurementMapTool::lengthWithGuideline() const +{ + return mLengthWithGuideline; +} + +bool MeasurementMapTool::canUndo() const +{ + return mCanUndo; +} + +void MeasurementMapTool::setCanUndo( bool newCanUndo ) +{ + if ( mCanUndo == newCanUndo ) + return; + + mCanUndo = newCanUndo; + emit canUndoChanged( mCanUndo ); +} + +bool MeasurementMapTool::canCloseShape() const +{ + return mCanCloseShape; +} + +void MeasurementMapTool::setCanCloseShape( bool newCanCloseShape ) +{ + if ( mCanCloseShape == newCanCloseShape ) + return; + + mCanCloseShape = newCanCloseShape; + emit canCloseShapeChanged( mCanCloseShape ); +} + +bool MeasurementMapTool::isValidGeometry() const +{ + return mIsValidGeometry; +} + +void MeasurementMapTool::setIsValidGeometry( bool hasValidGeometry ) +{ + if ( mIsValidGeometry != hasValidGeometry ) + { + mIsValidGeometry = hasValidGeometry; + emit isValidGeometryChanged( hasValidGeometry ); + } +} + +bool MeasurementMapTool::measurementFinalized() const +{ + return mMeasurementFinalized; +} + +void MeasurementMapTool::setMeasurementFinalized( bool newMeasurementFinalized ) +{ + if ( mMeasurementFinalized == newMeasurementFinalized ) + return; + + mMeasurementFinalized = newMeasurementFinalized; + emit measurementFinalizedChanged( mMeasurementFinalized ); +} + +void MeasurementMapTool::setRecordedGeometry( const QgsGeometry &newRecordedGeometry ) +{ + if ( mRecordedGeometry.equals( newRecordedGeometry ) ) + return; + + mRecordedGeometry = newRecordedGeometry; + emit recordedGeometryChanged( mRecordedGeometry ); +} + +void MeasurementMapTool::setLengthWithGuideline( const double &lengthWithGuideline ) +{ + if ( mLengthWithGuideline == lengthWithGuideline ) + return; + + mLengthWithGuideline = lengthWithGuideline; + emit lengthWithGuidelineChanged( lengthWithGuideline ); +} + +void MeasurementMapTool::setArea( const double &area ) +{ + if ( mArea == area ) + return; + + mArea = area; + emit areaChanged( area ); +} + +void MeasurementMapTool::setPerimeter( const double &perimeter ) +{ + if ( mPerimeter == perimeter ) + return; + + mPerimeter = perimeter; + emit perimeterChanged( perimeter ); +} + +void MeasurementMapTool::setCrosshairPoint( const QPointF &point ) +{ + if ( mCrosshairPoint == point ) + return; + + mCrosshairPoint = point; + emit crosshairPointChanged( mCrosshairPoint ); +} diff --git a/app/maptools/measurementmaptool.h b/app/maptools/measurementmaptool.h new file mode 100644 index 000000000..1d041b203 --- /dev/null +++ b/app/maptools/measurementmaptool.h @@ -0,0 +1,139 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef MEASUREMENTMAPTOOL_H +#define MEASUREMENTMAPTOOL_H + +#include "abstractmaptool.h" +#include +#include "qgsdistancearea.h" +#include "qgsgeometry.h" +#include "inpututils.h" +#include "qgspolygon.h" +#include "qgsgeometry.h" +#include "qgsvectorlayer.h" +#include "qgsmultipoint.h" + +const double CLOSE_THRESHOLD = 10.0; // in pixels + +class MeasurementMapTool : public AbstractMapTool +{ + Q_OBJECT + + Q_PROPERTY( QgsGeometry recordedGeometry READ recordedGeometry WRITE setRecordedGeometry NOTIFY recordedGeometryChanged ) + Q_PROPERTY( QgsGeometry existingVertices READ existingVertices WRITE setExistingVertices NOTIFY existingVerticesChanged ) + Q_PROPERTY( QPointF crosshairPoint READ crosshairPoint WRITE setCrosshairPoint NOTIFY crosshairPointChanged ) + + Q_PROPERTY( double lengthWithGuideline READ lengthWithGuideline WRITE setLengthWithGuideline NOTIFY lengthWithGuidelineChanged ) + Q_PROPERTY( double perimeter READ perimeter WRITE setPerimeter NOTIFY perimeterChanged ) + Q_PROPERTY( double area READ area WRITE setArea NOTIFY areaChanged ) + + Q_PROPERTY( bool canUndo READ canUndo WRITE setCanUndo NOTIFY canUndoChanged ) + Q_PROPERTY( bool canCloseShape READ canCloseShape WRITE setCanCloseShape NOTIFY canCloseShapeChanged ) + Q_PROPERTY( bool isValidGeometry READ isValidGeometry WRITE setIsValidGeometry NOTIFY isValidGeometryChanged ) + Q_PROPERTY( bool measurementFinalized READ measurementFinalized WRITE setMeasurementFinalized NOTIFY measurementFinalizedChanged ) + + public: + explicit MeasurementMapTool( QObject *parent = nullptr ); + virtual ~MeasurementMapTool() override; + + /** + * Adds point to the end of the recorded geometry; updates recordedGeometry afterwards + * Passed point needs to be in map CRS + */ + Q_INVOKABLE void addPoint(); + + /** + * Removes last point from recorded geometry if there is at least one point + * Updates recordedGeometry afterwards + */ + Q_INVOKABLE void removePoint(); + + /** + * Finalizes measurement by forming a polygon if "Close shape" button was clicked + * and there are at least 3 points; otherwise, if "Done" button was clicked, forms a polyline. + * Updates recorded geometry, calculates perimeter, and calculates the area if it's a polygon. + */ + Q_INVOKABLE void finalizeMeasurement( bool closeShapeClicked ); + + /** + * Repeats measurement process. + * Clears all recorded points and rebuilds the geometry. + */ + Q_INVOKABLE void resetMeasurement(); + + // Getter and Setters + double lengthWithGuideline() const; + void setLengthWithGuideline( const double &length ); + + double perimeter() const; + void setPerimeter( const double &perimeter ); + + double area() const; + void setArea( const double &area ); + + QPointF crosshairPoint() const; + void setCrosshairPoint( const QPointF &point ); + + bool canUndo() const; + void setCanUndo( bool newCanUndo ); + + bool canCloseShape() const; + void setCanCloseShape( bool newCanCloseShape ); + + bool isValidGeometry() const; + void setIsValidGeometry( bool hasValidGeometry ); + + bool measurementFinalized() const; + void setMeasurementFinalized( bool newMeasurementFinalized ); + + const QgsGeometry &recordedGeometry() const; + void setRecordedGeometry( const QgsGeometry &newRecordedGeometry ); + + QgsGeometry existingVertices() const; + void setExistingVertices( const QgsGeometry &vertices ); + + void resetMapSettings(); + void updateMapSettings( InputMapSettings *newMapSettings ); + + signals: + void lengthWithGuidelineChanged( const double &lengthWithGuideline ); + void perimeterChanged( const double &perimeter ); + void areaChanged( const double &area ); + void canUndoChanged( bool canUndo ); + void canCloseShapeChanged( bool canUndo ); + void measurementFinalizedChanged( bool measurementFinalized ); + void recordedGeometryChanged( const QgsGeometry &recordedGeometry ); + void existingVerticesChanged( const QgsGeometry &vertices ); + void crosshairPointChanged( const QPointF &crosshairPoint ); + void isValidGeometryChanged( bool canFinalize ); + + protected: + void rebuildGeometry(); + void checkCanCloseShape(); + + public slots: + void updateDistance(); + + private: + QVector mPoints; + QgsGeometry mRecordedGeometry; + QgsGeometry mExistingVertices; + QgsDistanceArea mDistanceArea; + QPointF mCrosshairPoint; + double mLengthWithGuideline = 0; + double mPerimeter = 0; + double mArea = 0; + bool mCanUndo = false; + bool mCanCloseShape = false; + bool mIsValidGeometry = false; + bool mMeasurementFinalized = false; +}; + +#endif // MEASUREMENTMAPTOOL_H diff --git a/app/mmstyle.h b/app/mmstyle.h index 7d65b0e91..16587bb81 100644 --- a/app/mmstyle.h +++ b/app/mmstyle.h @@ -168,6 +168,8 @@ class MMStyle: public QObject Q_PROPERTY( QUrl streamingIcon READ streamingIcon CONSTANT ) Q_PROPERTY( QUrl redrawGeometryIcon READ redrawGeometryIcon CONSTANT ) Q_PROPERTY( QUrl cloudIcon READ cloudIcon CONSTANT ) + Q_PROPERTY( QUrl measurementToolIcon READ measurementToolIcon CONSTANT ) + Q_PROPERTY( QUrl closeShapeIcon READ closeShapeIcon CONSTANT ) // Filled Icons - for visualizing of selected item in toolbar Q_PROPERTY( QUrl projectsFilledIcon READ projectsFilledIcon CONSTANT ) @@ -215,6 +217,7 @@ class MMStyle: public QObject Q_PROPERTY( QUrl externalGpsRedImage READ externalGpsRedImage CONSTANT ) Q_PROPERTY( QUrl negativeMMSymbolImage READ negativeMMSymbolImage CONSTANT ) Q_PROPERTY( QUrl positiveMMSymbolImage READ positiveMMSymbolImage CONSTANT ) + Q_PROPERTY( QUrl neutralMMSymbolImage READ neutralMMSymbolImage CONSTANT ) Q_PROPERTY( QUrl closeAccountImage READ closeAccountImage CONSTANT ) Q_PROPERTY( QUrl attentionImage READ attentionImage CONSTANT ) Q_PROPERTY( QUrl blueInfoImage READ blueInfoImage CONSTANT ) @@ -276,7 +279,9 @@ class MMStyle: public QObject // Page Q_PROPERTY( double pageMargins READ number20 CONSTANT ) // distance between screen edge and components + Q_PROPERTY( double spacing2 READ number2 CONSTANT ) Q_PROPERTY( double spacing5 READ number5 CONSTANT ) + Q_PROPERTY( double spacing10 READ number10 CONSTANT ) Q_PROPERTY( double spacing12 READ number12 CONSTANT ) // distance between page header, page content and page footer Q_PROPERTY( double spacing20 READ number20 CONSTANT ) Q_PROPERTY( double spacing30 READ number30 CONSTANT ) @@ -422,6 +427,8 @@ class MMStyle: public QObject QUrl moreVerticalIcon() {return QUrl( "qrc:/MoreVertical.svg" );} QUrl morePhotosIcon() {return QUrl( "qrc:/MorePhotos.svg" );} QUrl mouthIcon() {return QUrl( "qrc:/Mouth.svg" );} + QUrl measurementToolIcon() {return QUrl( "qrc:/Measure.svg" );} + QUrl closeShapeIcon() {return QUrl( "qrc:/CloseShape.svg" );} QUrl naturalResourcesIcon() {return QUrl( "qrc:/NaturalResources.svg" );} QUrl nextIcon() {return QUrl( "qrc:/Next.svg" );} QUrl otherIcon() {return QUrl( "qrc:/Other.svg" );} @@ -498,6 +505,7 @@ class MMStyle: public QObject QUrl externalGpsRedImage() {return QUrl( "qrc:/images/ExternalGpsRed.svg" );} QUrl negativeMMSymbolImage() {return QUrl( "qrc:/images/NegativeMMSymbol.svg" );} QUrl positiveMMSymbolImage() {return QUrl( "qrc:/images/PositiveMMSymbol.svg" );} + QUrl neutralMMSymbolImage() {return QUrl( "qrc:/images/NeutralMMSymbol.svg" );} QUrl closeAccountImage() {return QUrl( "qrc:/images/CloseAccount.svg" );} QUrl attentionImage() {return QUrl( "qrc:/images/Attention.svg" );} QUrl blueInfoImage() {return QUrl( "qrc:/images/BlueInfo.svg" );} diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index bf848bd61..ccf99a8d5 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -116,6 +116,7 @@ set(MM_QML gps/MMGpsDataDrawer.qml gps/MMPositionProviderPage.qml gps/MMStakeoutDrawer.qml + gps/MMMeasureDrawer.qml gps/components/MMGpsDataText.qml inputs/MMComboboxInput.qml inputs/MMPasswordInput.qml @@ -135,8 +136,10 @@ set(MM_QML map/MMSplittingTools.qml map/MMStakeoutTools.qml map/MMRecordingTools.qml + map/MMMeasurementTools.qml map/components/MMHidingBox.qml map/components/MMCrosshair.qml + map/components/MMMeasureCrosshair.qml map/components/MMMapHidingLabel.qml map/components/MMMapButton.qml map/components/MMMapLabel.qml diff --git a/app/qml/components/MMButton.qml b/app/qml/components/MMButton.qml index 631a26b3e..3cce97fc9 100644 --- a/app/qml/components/MMButton.qml +++ b/app/qml/components/MMButton.qml @@ -15,8 +15,10 @@ Button { id: root enum Types { Primary, Secondary, Tertiary } + enum Sizes { Small, Regular } property int type: MMButton.Types.Primary + property int size: MMButton.Sizes.Regular property color fontColor: { if ( type === MMButton.Types.Primary ) return __style.forestColor @@ -161,10 +163,10 @@ Button { state: "default" implicitHeight: root.type === MMButton.Types.Tertiary ? buttonContent.height : buttonContent.height + topPadding + bottomPadding - implicitWidth: row.paintedChildrenWidth + 2 * __style.margin20 + implicitWidth: row.paintedChildrenWidth + 2 * ( root.size === MMButton.Sizes.Small ? __style.margin16 : __style.margin20 ) - topPadding: root.type === MMButton.Types.Tertiary ? 0 : 11 * __dp - bottomPadding: root.type === MMButton.Types.Tertiary ? 0 : 11 * __dp + topPadding: ( root.type === MMButton.Types.Tertiary ) ? 0 : 11 * __dp + bottomPadding: ( root.type === MMButton.Types.Tertiary ) ? 0 : 11 * __dp rightPadding: 0 leftPadding: 0 @@ -177,14 +179,21 @@ Button { id: row property real paintedChildrenWidth: buttonIconLeft.paintedWidth + buttonContent.implicitWidth + buttonIconRight.paintedWidth + spacing - property real maxWidth: parent.width - 2 * __style.margin20 + property real maxWidth: parent.width - 2 * ( root.size === MMButton.Sizes.Small ? __style.margin16 : __style.margin20 ) x: ( parent.width - width ) / 2 width: Math.min( paintedChildrenWidth, maxWidth ) height: Math.max( buttonContent.paintedHeight, buttonIconRight.height ) - spacing: buttonIconRight.visible || buttonIconLeft.visible ? __style.spacing12 : 0 + spacing: { + if ( ( root.size === MMButton.Sizes.Small ) ) + return __style.spacing2; + else if ( ( buttonIconRight.visible || buttonIconLeft.visible ) ) + return __style.spacing12; + else + return 0; + } MMIcon { id: buttonIconLeft diff --git a/app/qml/components/MMDrawerHeader.qml b/app/qml/components/MMDrawerHeader.qml index 8d5a5cfc2..b40c2ce1e 100644 --- a/app/qml/components/MMDrawerHeader.qml +++ b/app/qml/components/MMDrawerHeader.qml @@ -22,7 +22,9 @@ Rectangle { property font titleFont: __style.t3 property bool hasCloseButton: true + property alias closeButton: closeBtn + property alias topLeftItemContent: topLeftButtonGroup.children color: __style.transparentColor @@ -31,14 +33,26 @@ Rectangle { implicitHeight: 60 * __dp implicitWidth: ApplicationWindow.window?.width ?? 0 + Item { + id: topLeftButtonGroup + + width: childrenRect.width + height: parent.height + } + Text { - // If the close button is visible, we need to properly center the text - property real margin: internal.closeBtnRealWidth + internal.headerSpacing + __style.pageMargins + property real leftMarginShift: { + return Math.max( internal.closeBtnRealWidth, topLeftButtonGroup.width ) + internal.headerSpacing + __style.pageMargins + } + + property real rightMarginShift: { + return Math.max( internal.closeBtnRealWidth, topLeftButtonGroup.width ) + internal.headerSpacing + __style.pageMargins + } anchors { fill: parent - leftMargin: margin - rightMargin: margin + leftMargin: leftMarginShift + rightMargin: rightMarginShift } text: root.title diff --git a/app/qml/gps/MMMeasureDrawer.qml b/app/qml/gps/MMMeasureDrawer.qml new file mode 100644 index 000000000..75910e175 --- /dev/null +++ b/app/qml/gps/MMMeasureDrawer.qml @@ -0,0 +1,119 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt5Compat.GraphicalEffects +import QtQuick.Shapes + +import mm 1.0 as MM + +import "../components" +import "../map/components" +import "./components" as MMGpsComponents + +MMDrawer { + id: root + + property var mapTool + + readonly property alias panelHeight: root.height + + property bool canCloseShape: mapTool?.canCloseShape ?? false + property bool canUndo: mapTool?.canUndo ?? false + property bool isValidGeometry: mapTool?.isValidGeometry ?? false + property bool measurementFinalized: mapTool?.measurementFinalized ?? false + + property string perimeter: mapTool?.perimeter ?? 0 + property string area: mapTool?.area ?? 0 + property bool isPolygon: area > 0 + + signal measureFinished() + + Component.onCompleted: root.open() + + modal: false + interactive: false + closePolicy: Popup.CloseOnEscape + + dropShadow: true + + onClosed: root.measureFinished() + + Behavior on implicitHeight { + PropertyAnimation { properties: "implicitHeight"; easing.type: Easing.InOutQuad } + } + + drawerHeader.title: qsTr( "Measure" ) + + drawerHeader.topLeftItemContent: MMButton { + type: MMButton.Types.Primary + text: measurementFinalized ? qsTr( "Repeat" ) : qsTr( "Undo" ) + iconSourceLeft: measurementFinalized ? __style.syncIcon : __style.undoIcon + bgndColor: __style.lightGreenColor + size: MMButton.Sizes.Small + enabled: measurementFinalized || canUndo + + anchors { + left: parent.left + leftMargin: __style.pageMargins + __style.safeAreaLeft + verticalCenter: parent.verticalCenter + } + + onClicked: measurementFinalized ? root.mapTool.resetMeasurement() : root.mapTool.removePoint() + } + + drawerContent: Column { + id: mainColumn + + width: parent.width + spacing: __style.margin10 + + Row { + width: parent.width + + MMGpsComponents.MMGpsDataText{ + width: ( parent.width + parent.spacing ) / 2 + + title: measurementFinalized && root.isPolygon ? qsTr( "Perimeter" ) : qsTr( "Length" ) //Perimeter only if its a polygon + value: __inputUtils.formatDistanceInProjectUnit( root.perimeter, 1, __activeProject.qgsProject ) + } + + MMGpsComponents.MMGpsDataText{ + width: ( parent.width + parent.spacing ) / 2 + + title: qsTr( "Area" ) + value: __inputUtils.formatAreaInProjectUnit( root.area, 1, __activeProject.qgsProject ) + alignmentRight: true + visible: measurementFinalized && root.isPolygon + } + } + + Row { + width: parent.width + spacing: __style.margin12 + visible: !root.measurementFinalized + + MMButton { + text: root.canCloseShape ? qsTr( "Close shape" ) : qsTr( "Add point" ) + iconSourceLeft: canCloseShape ? __style.closeShapeIcon : __style.plusIcon + onClicked: canCloseShape ? root.mapTool.finalizeMeasurement( true ) : root.mapTool.addPoint() + } + + MMButton { + type: MMButton.Types.Secondary + text: qsTr( "Done" ) + iconSourceLeft: __style.doneCircleIcon + enabled: root.isValidGeometry + onClicked: root.mapTool.finalizeMeasurement( false ) + } + } + } +} diff --git a/app/qml/gps/MMStakeoutDrawer.qml b/app/qml/gps/MMStakeoutDrawer.qml index 918436976..61f6ca354 100644 --- a/app/qml/gps/MMStakeoutDrawer.qml +++ b/app/qml/gps/MMStakeoutDrawer.qml @@ -97,7 +97,6 @@ MMDrawer { Row { width: parent.width - spacing: __style.margi8 MMGpsComponents.MMGpsDataText{ width: ( parent.width + parent.spacing ) / 2 @@ -110,7 +109,7 @@ MMDrawer { width: ( parent.width + parent.spacing ) / 2 title: qsTr( "Distance" ) - value: remainingDistance >= 0 ?__inputUtils.formatDistanceInProjectUnit( remainingDistance, 2 ) : qsTr( "N/A" ) + value: remainingDistance >= 0 ?__inputUtils.formatDistanceInProjectUnit( remainingDistance, 2, __activeProject.qgsProject ) : qsTr( "N/A" ) alignmentRight: true } } diff --git a/app/qml/main.qml b/app/qml/main.qml index 822e7bd41..d6b7ea116 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -158,6 +158,10 @@ ApplicationWindow { // if stakeout panel is opened return stakeoutPanelLoader.item.panelHeight - mapToolbar.height } + else if ( measurePanelLoader.active ) + { + return measurePanelLoader.item.panelHeight - mapToolbar.height + } else if ( formsStackManager.takenVerticalSpace > 0 ) { // if feature preview panel is opened @@ -219,6 +223,11 @@ ApplicationWindow { stakeoutPanelLoader.item.targetPair = pair } + onMeasureStarted: function( pair ) { + measurePanelLoader.active = true + measurePanelLoader.focus = true + } + onLocalChangesPanelRequested: { stateManager.state = "projects" projectController.openChangesPanel( __activeProject.projectFullName(), true ) @@ -326,6 +335,12 @@ ApplicationWindow { } } + MMToolbarButton { + text: qsTr("Measure") + iconSource: __style.measurementToolIcon + onClicked: map.measure() + } + MMToolbarButton { text: qsTr("Local changes") iconSource: __style.localChangesIcon @@ -596,6 +611,32 @@ ApplicationWindow { } } + Loader { + id: measurePanelLoader + + focus: true + active: false + asynchronous: true + + sourceComponent: measurePanelComponent + } + + Component { + id: measurePanelComponent + + MMMeasureDrawer { + id: measurePanel + + width: window.width + mapTool: map.mapToolComponent + + onMeasureFinished: { + measurePanelLoader.active = false + map.finishMeasure() + } + } + } + MMFormStackController { id: formsStackManager diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index a1c8c7fb4..43f76d8af 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -35,6 +35,10 @@ Item { property bool isStreaming: recordingToolsLoader.active ? recordingToolsLoader.item.recordingMapTool.recordingType === MM.RecordingMapTool.StreamMode : false property bool centeredToGPS: false + property var mapToolComponent: { + measurementToolsLoader.active ? measurementToolsLoader.item.mapTool : null + } + property MM.PositionTrackingManager trackingManager: tracking.item?.manager ?? null signal featureIdentified( var pair ) @@ -60,6 +64,8 @@ Item { signal stakeoutStarted( var pair ) signal accuracyButtonClicked() + signal measureStarted() + signal localChangesPanelRequested() signal openTrackingPanel() @@ -86,6 +92,9 @@ Item { State { name: "stakeout" }, + State { + name: "measure" + }, State { name: "inactive" // ignores touch input } @@ -143,6 +152,13 @@ Item { break } + case "measure": { + root.showInfoTextMessage( qsTr( "Add points to measure distance, close the shape to measure area" ) ) + root.hideHighlight() + root.measureStarted() + break + } + case "inactive": { break } @@ -381,6 +397,17 @@ Item { sourceComponent: splittingToolsComponent } + Loader { + id: measurementToolsLoader + + anchors.fill: mapCanvas + + asynchronous: true + active: root.state === "measure" + + sourceComponent: measurementToolsComponent + } + // map available content within safe area Item { anchors { @@ -497,10 +524,10 @@ Item { anchors.bottom: parent.bottom - anchors.bottomMargin: root.state === "stakeout" ? root.mapExtentOffset : 0 + anchors.bottomMargin: root.state === "stakeout" || root.state === "measure" ? root.mapExtentOffset : 0 visible: { - if ( root.state === "stakeout" ) + if ( root.state === "stakeout" || root.state === "measure" ) return true else return root.mapExtentOffset > 0 ? false : true @@ -962,6 +989,18 @@ Item { } } + Component { + id: measurementToolsComponent + + MMMeasurementTools { + anchors.fill: parent + + map: mapCanvas + positionMarkerComponent: positionMarker + onFinishMeasurement: root.finishMeasure() + } + } + Component { id: splittingToolsComponent @@ -1111,6 +1150,10 @@ Item { state = "stakeout" } + function measure() { + state = "measure" + } + function toggleStreaming() { // start/stop the streaming mode if ( recordingToolsLoader.active ) { @@ -1127,6 +1170,10 @@ Item { root.centeredToGPS = internal.centeredToGPSBeforeStakeout } + function finishMeasure() { + state = "view" + } + function centerToPair( pair ) { __inputUtils.setExtentToFeature( pair, mapCanvas.mapSettings ) } diff --git a/app/qml/map/MMMeasurementTools.qml b/app/qml/map/MMMeasurementTools.qml new file mode 100644 index 000000000..e4a785961 --- /dev/null +++ b/app/qml/map/MMMeasurementTools.qml @@ -0,0 +1,101 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick +import QtQuick.Shapes + +import mm 1.0 as MM + +import "../components" +import "./components" +import "../gps" +import "../dialogs" + +Item { + id: root + + required property MMMapCanvas map + required property MMPositionMarker positionMarkerComponent + + property var mapTool: mapTool + + signal finishMeasurement() + + MM.MeasurementMapTool { + id: mapTool + + mapSettings: root.map.mapSettings + crosshairPoint: crosshair.screenPoint + } + + MM.GuidelineController { + id: guidelineController + + allowed: !mapTool.measurementFinalized + mapSettings: root.map.mapSettings + crosshairPosition: crosshair.screenPoint + realGeometry: mapTool.recordedGeometry + } + + MMHighlight { + id: guideline + + height: root.map.height + width: root.map.width + + markerColor: __style.deepOceanColor + lineColor: __style.deepOceanColor + lineStrokeStyle: ShapePath.DashLine + lineWidth: MMHighlight.LineWidths.Narrow + + mapSettings: root.map.mapSettings + geometry: guidelineController.guidelineGeometry + } + + MMHighlight { + id: highlight + + height: map.height + width: map.width + + markerColor: __style.deepOceanColor + lineColor: __style.deepOceanColor + lineWidth: MMHighlight.LineWidths.Narrow + + mapSettings: root.map.mapSettings + geometry: mapTool.recordedGeometry + } + + MMHighlight { + id: existingVerticesHighlight + + height: root.map.height + width: root.map.width + + mapSettings: root.map.mapSettings + geometry: mapTool.existingVertices + + markerType: MMHighlight.MarkerTypes.Circle + markerSize: MMHighlight.MarkerSizes.Bigger + } + + MMMeasureCrosshair { + id: crosshair + + anchors.fill: parent + qgsProject: __activeProject.qgsProject + mapSettings: root.map.mapSettings + visible: !mapTool.measurementFinalized + + text: __inputUtils.formatDistanceInProjectUnit( mapTool.lengthWithGuideline, 1, __activeProject.qgsProject ) + canCloseShape: mapTool.canCloseShape + + onCloseShapeClicked: root.mapTool.finalizeMeasurement( true ) + } +} diff --git a/app/qml/map/components/MMCrosshair.qml b/app/qml/map/components/MMCrosshair.qml index a9c7ec465..82892ff25 100644 --- a/app/qml/map/components/MMCrosshair.qml +++ b/app/qml/map/components/MMCrosshair.qml @@ -28,6 +28,9 @@ Item { property real outerSize: 60 * __dp property real innerDotSize: 10 * __dp + property alias crosshairForeground: crosshairForeground + property alias snapUtils: snapUtils + MM.SnapUtils { id: snapUtils diff --git a/app/qml/map/components/MMMapHidingLabel.qml b/app/qml/map/components/MMMapHidingLabel.qml index 7a0ab3720..63941982d 100644 --- a/app/qml/map/components/MMMapHidingLabel.qml +++ b/app/qml/map/components/MMMapHidingLabel.qml @@ -24,12 +24,14 @@ MMHidingBox { root.visible = false } - height: __style.row40 + height: text.lineCount * __style.row40 timerInterval: 10000 fadeOutDuration: 1000 // Text Text { + id: text + height: parent.height width: parent.width - 2 * __style.pageMargins anchors.verticalCenter: parent.verticalCenter @@ -40,6 +42,8 @@ MMHidingBox { font: __style.t3 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + maximumLineCount: 3 + wrapMode: Text.WordWrap elide: Text.ElideRight } } diff --git a/app/qml/map/components/MMMeasureCrosshair.qml b/app/qml/map/components/MMMeasureCrosshair.qml new file mode 100644 index 000000000..3102def00 --- /dev/null +++ b/app/qml/map/components/MMMeasureCrosshair.qml @@ -0,0 +1,106 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick +import Qt5Compat.GraphicalEffects + +import mm 1.0 as MM +import "../../components" + +Item { + id: root + + required property string text + /*required*/ property var qgsProject + /*required*/ property var mapSettings + + property bool canCloseShape: false + property bool shouldUseSnapping: false + + property point center: Qt.point( root.width / 2, root.height / 2 ) + + property var recordPoint: crosshair.recordPoint + + property point screenPoint: crosshair.snapUtils.snapped && __activeLayer.vectorLayer ? __inputUtils.transformPointToScreenCoordinates(__activeLayer.vectorLayer.crs, mapSettings, recordPoint) : center + + property real outerSize: 60 * __dp + property real innerDotSize: 10 * __dp + + implicitWidth: row.width + implicitHeight: __style.mapItemHeight + + signal closeShapeClicked + + MMCrosshair { + id: crosshair + + anchors.fill: parent + qgsProject: __activeProject.qgsProject + mapSettings: root.mapSettings + } + + Rectangle { + y: crosshair.crosshairForeground.y + crosshair.crosshairForeground.height + __style.spacing2 + x: crosshair.crosshairForeground.x - ( ( width - crosshair.crosshairForeground.width ) / 2 ) + + width: Math.max( root.outerSize - __style.spacing10 , row.width ) + height: root.outerSize * 0.6 + radius: root.height / 2 + color: textBg.color + + layer.enabled: true + layer.effect: MMShadow {} + + Row { + id: row + + anchors.centerIn: parent + leftPadding: 8 * __dp + rightPadding: leftPadding + spacing: 4 * __dp + height: parent.height + + MMIcon { + id: icon + anchors.verticalCenter: parent.verticalCenter + source: root.canCloseShape ? __style.closeShapeIcon : "" + size: root.canCloseShape ? __style.icon24 : 0 + } + + Rectangle { + id: textBg + property real spacing: __style.spacing5 + anchors.verticalCenter: parent.verticalCenter + color: root.canCloseShape ? __style.grassColor : __style.forestColor + height: text.height + spacing + width: text.width + 3 * spacing + radius: height / 2 + + MMText { + id: text + + property real textSurroundingItemsWidth: textBg.spacing + icon.width + row.spacing + 2 * row.leftPadding + + width: ( implicitWidth + textSurroundingItemsWidth ) > root.maxWidth ? root.maxWidth - textSurroundingItemsWidth : implicitWidth + anchors.centerIn: parent + color: root.canCloseShape ? __style.forestColor: __style.polarColor + text: root.canCloseShape ? qsTr( "Close shape" ) : root.text + font: __style.t3 + elide: Text.ElideRight + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.canCloseShape + onClicked: root.closeShapeClicked() + } + } +} diff --git a/app/test/testmaptools.cpp b/app/test/testmaptools.cpp index 3b359a399..c90fb4de2 100644 --- a/app/test/testmaptools.cpp +++ b/app/test/testmaptools.cpp @@ -29,6 +29,7 @@ #include "snaputils.h" #include "maptools/splittingmaptool.h" #include "maptools/recordingmaptool.h" +#include "maptools/measurementmaptool.h" #include "featurelayerpair.h" #include "streamingintervaltype.h" @@ -276,6 +277,87 @@ void TestMapTools::testRecording() delete recordTool; } +void TestMapTools::testMeasuring() +{ + MeasurementMapTool *measurementTool = new MeasurementMapTool(); + + InputMapCanvasMap canvas; + InputMapSettings *ms = canvas.mapSettings(); + + QgsProject *project = TestUtils::loadPlanesTestProject(); + QVERIFY( project && !project->homePath().isEmpty() ); + + setupMapSettings( ms, project, QgsRectangle( -10, -10, 10, 10 ), QSize( 600, 600 ) ); + + measurementTool->setMapSettings( ms ); + + QgsDistanceArea distanceArea; + distanceArea.setEllipsoid( ms->project()->ellipsoid() ); + distanceArea.setSourceCrs( ms->destinationCrs(), ms->transformContext() ); + + QPointF crosshairPoint1 = ms->coordinateToScreen( QgsPoint( 0, 0 ) ); + measurementTool->setCrosshairPoint( crosshairPoint1 ); + measurementTool->addPoint(); + + measurementTool->updateDistance(); + QCOMPARE( measurementTool->lengthWithGuideline(), 0.0 ); + + QPointF crosshairPoint2 = ms->coordinateToScreen( QgsPoint( 0, 1 ) ); + measurementTool->setCrosshairPoint( crosshairPoint2 ); + measurementTool->addPoint(); + + measurementTool->updateDistance(); + + QList points; + points.append( QgsPointXY( 0, 0 ) ); + points.append( QgsPointXY( 0, 1 ) ); + + QgsGeometry lineGeometry = QgsGeometry::fromPolylineXY( points ); + double expectedPerimeter = distanceArea.measureLength( lineGeometry ); + QCOMPARE( measurementTool->perimeter(), expectedPerimeter ); + + QPointF crosshairPoint3 = ms->coordinateToScreen( QgsPoint( 1, 1 ) ); + measurementTool->setCrosshairPoint( crosshairPoint3 ); + measurementTool->addPoint(); + + measurementTool->updateDistance(); + points.append( QgsPointXY( 1, 1 ) ); + lineGeometry = QgsGeometry::fromPolylineXY( points ); + expectedPerimeter = distanceArea.measureLength( lineGeometry ); + QCOMPARE( measurementTool->perimeter(), expectedPerimeter ); + + measurementTool->finalizeMeasurement( false ); + + QVERIFY( measurementTool->recordedGeometry().wkbType() == Qgis::WkbType::LineString ); + + measurementTool->resetMeasurement(); + + measurementTool->setCrosshairPoint( crosshairPoint1 ); + measurementTool->addPoint(); + + measurementTool->setCrosshairPoint( crosshairPoint2 ); + measurementTool->addPoint(); + + measurementTool->setCrosshairPoint( crosshairPoint3 ); + measurementTool->addPoint(); + + measurementTool->setCrosshairPoint( crosshairPoint1 ); + measurementTool->updateDistance(); + + QVERIFY( measurementTool->canCloseShape() ); + + measurementTool->finalizeMeasurement( true ); + + QVERIFY( measurementTool->recordedGeometry().wkbType() == Qgis::WkbType::Polygon ); + + QgsGeometry polygonGeometry = QgsGeometry::fromPolygonXY( QList>() << points ); + double expectedArea = distanceArea.measureArea( polygonGeometry ); + QCOMPARE( measurementTool->area(), expectedArea ); + + delete project; + delete measurementTool; +} + void TestMapTools::testExistingVertices() { RecordingMapTool mapTool; diff --git a/app/test/testmaptools.h b/app/test/testmaptools.h index deb24e0c4..e06c748aa 100644 --- a/app/test/testmaptools.h +++ b/app/test/testmaptools.h @@ -29,6 +29,7 @@ class TestMapTools : public QObject void testSnapping(); void testSplitting(); void testRecording(); + void testMeasuring(); void testExistingVertices(); void testMidSegmentVertices(); diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 603d8e319..564b8458b 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -856,35 +856,93 @@ void TestUtilsFunctions::testParsePositionUpdates() } } -void TestUtilsFunctions::testFormatDistanceInDistanceUnit() +void TestUtilsFunctions::testFormatDistanceInProjectUnit() { - QString dist2str = mUtils->formatDistanceInProjectUnit( 1222.234, 2, Qgis::DistanceUnit::Meters ); + QgsProject *project = QgsProject::instance(); + QVERIFY( project != nullptr ); + + // Set the project distance units to meters + project->setDistanceUnits( Qgis::DistanceUnit::Meters ); + + QString dist2str = mUtils->formatDistanceInProjectUnit( 1222.234, 2, project ); QVERIFY( dist2str == "1222.23 m" ); - dist2str = mUtils->formatDistanceInProjectUnit( 1222.234, 1, Qgis::DistanceUnit::Meters ); + dist2str = mUtils->formatDistanceInProjectUnit( 1222.234, 1, project ); QVERIFY( dist2str == "1222.2 m" ); - dist2str = mUtils->formatDistanceInProjectUnit( 1222.234, 0, Qgis::DistanceUnit::Meters ); + dist2str = mUtils->formatDistanceInProjectUnit( 1222.234, 0, project ); QVERIFY( dist2str == "1222 m" ); - dist2str = mUtils->formatDistanceInProjectUnit( 700.22, 1, Qgis::DistanceUnit::Meters ); + dist2str = mUtils->formatDistanceInProjectUnit( 700.22, 1, project ); QVERIFY( dist2str == "700.2 m" ); - dist2str = mUtils->formatDistanceInProjectUnit( 0.22, 0, Qgis::DistanceUnit::Meters ); + dist2str = mUtils->formatDistanceInProjectUnit( 0.22, 0, project ); QVERIFY( dist2str == "0 m" ); - dist2str = mUtils->formatDistanceInProjectUnit( -0.22, 0, Qgis::DistanceUnit::Meters ); + dist2str = mUtils->formatDistanceInProjectUnit( -0.22, 0, project ); QVERIFY( dist2str == "-0 m" ); - dist2str = mUtils->formatDistanceInProjectUnit( 1.222234, 2, Qgis::DistanceUnit::Kilometers ); + // Change project distance units to kilometers + project->setDistanceUnits( Qgis::DistanceUnit::Kilometers ); + + dist2str = mUtils->formatDistanceInProjectUnit( 1.222234, 2, project ); QVERIFY( dist2str == "0.00 km" ); - dist2str = mUtils->formatDistanceInProjectUnit( 6000, 1, Qgis::DistanceUnit::Feet ); + // Change project distance units to feet + project->setDistanceUnits( Qgis::DistanceUnit::Feet ); + + dist2str = mUtils->formatDistanceInProjectUnit( 6000, 1, project ); QVERIFY( dist2str == "19685.0 ft" ); - dist2str = mUtils->formatDistanceInProjectUnit( 5, 1, Qgis::DistanceUnit::Feet ); + dist2str = mUtils->formatDistanceInProjectUnit( 5, 1, project ); QVERIFY( dist2str == "16.4 ft" ); - dist2str = mUtils->formatDistanceInProjectUnit( 7000, 1, Qgis::DistanceUnit::Feet ); + dist2str = mUtils->formatDistanceInProjectUnit( 7000, 1, project ); QVERIFY( dist2str == "22965.9 ft" ); } + + +void TestUtilsFunctions::testFormatAreaInProjectUnit() +{ + QgsProject *project = QgsProject::instance(); + QVERIFY( project != nullptr ); + + // Set project area units to square meters + project->setAreaUnits( Qgis::AreaUnit::SquareMeters ); + + QString area2str = mUtils->formatAreaInProjectUnit( 1500.234, 2, project ); + QVERIFY( area2str == "1500.23 m²" ); + + area2str = mUtils->formatAreaInProjectUnit( 1500.234, 1, project ); + QVERIFY( area2str == "1500.2 m²" ); + + area2str = mUtils->formatAreaInProjectUnit( 1500.234, 0, project ); + QVERIFY( area2str == "1500 m²" ); + + area2str = mUtils->formatAreaInProjectUnit( 500.22, 1, project ); + QVERIFY( area2str == "500.2 m²" ); + + area2str = mUtils->formatAreaInProjectUnit( 0.22, 0, project ); + QVERIFY( area2str == "0 m²" ); + + area2str = mUtils->formatAreaInProjectUnit( -0.22, 0, project ); + QVERIFY( area2str == "-0 m²" ); + + // Change project area units to square kilometers + project->setAreaUnits( Qgis::AreaUnit::SquareKilometers ); + + area2str = mUtils->formatAreaInProjectUnit( 1.222234, 2, project ); + QVERIFY( area2str == "0.00 km²" ); + + // Change project area units to acres + project->setAreaUnits( Qgis::AreaUnit::Acres ); + + area2str = mUtils->formatAreaInProjectUnit( 6000, 1, project ); + QVERIFY( area2str == "1.5 ac" ); + + area2str = mUtils->formatAreaInProjectUnit( 5, 1, project ); + QVERIFY( area2str == "0.0 ac" ); + + area2str = mUtils->formatAreaInProjectUnit( 7000, 1, project ); + QVERIFY( area2str == "1.7 ac" ); +} diff --git a/app/test/testutilsfunctions.h b/app/test/testutilsfunctions.h index f74cf140f..75b497c78 100644 --- a/app/test/testutilsfunctions.h +++ b/app/test/testutilsfunctions.h @@ -48,7 +48,8 @@ class TestUtilsFunctions: public QObject void testInvalidGeometryWarning(); void testAttribution(); void testParsePositionUpdates(); - void testFormatDistanceInDistanceUnit(); + void testFormatDistanceInProjectUnit(); + void testFormatAreaInProjectUnit(); private: void testFormatDuration( const QDateTime &t0, qint64 diffSecs, const QString &expectedResult ); diff --git a/gallery/qml/pages/ButtonsPage.qml b/gallery/qml/pages/ButtonsPage.qml index 97fabbb3e..4e0bc636e 100644 --- a/gallery/qml/pages/ButtonsPage.qml +++ b/gallery/qml/pages/ButtonsPage.qml @@ -87,8 +87,24 @@ ScrollView { iconSourceLeft: __style.uploadIcon } - MMButton { - text: "Primary flex width (no witdth set)" + Row { + width: parent.width + spacing: 20 + + MMButton { + text: "Primary flex width (no witdth set)" + } + + MMButton { + text: "Small Primary" + size: MMButton.Sizes.Small + } + + MMButton { + text: "Small Primary with Icon" + iconSourceLeft: __style.syncIcon + size: MMButton.Sizes.Small + } } MMListSpacer { height: __style.margin20 } @@ -142,11 +158,29 @@ ScrollView { iconSourceRight: __style.arrowLinkRightIcon } - MMButton { - type: MMButton.Types.Secondary - text: "Secondary flex width (no witdth set)" - iconSourceLeft: __style.uploadIcon - iconSourceRight: __style.uploadIcon + Row { + width: parent.width + spacing: 20 + + MMButton { + type: MMButton.Types.Secondary + text: "Secondary flex width (no witdth set)" + iconSourceLeft: __style.uploadIcon + iconSourceRight: __style.uploadIcon + } + + MMButton { + text: "Small Secondary" + type: MMButton.Types.Secondary + size: MMButton.Sizes.Small + } + + MMButton { + text: "Small Secondary with Icon" + type: MMButton.Types.Secondary + iconSourceLeft: __style.syncIcon + size: MMButton.Sizes.Small + } } MMListSpacer { height: __style.margin20 } @@ -200,11 +234,29 @@ ScrollView { iconSourceRight: __style.arrowLinkRightIcon } - MMButton { - type: MMButton.Types.Tertiary - text: "Tertiary flex width (no witdth set)" - fontColor: __style.nightColor - iconSourceLeft: __style.globeIcon + Row { + width: parent.width + spacing: 20 + + MMButton { + type: MMButton.Types.Tertiary + text: "Tertiary flex width (no witdth set)" + fontColor: __style.nightColor + iconSourceLeft: __style.globeIcon + } + + MMButton { + text: "Small Tertiary" + type: MMButton.Types.Tertiary + size: MMButton.Sizes.Small + } + + MMButton { + text: "Small Tertiary with Icon" + type: MMButton.Types.Tertiary + iconSourceLeft: __style.syncIcon + size: MMButton.Sizes.Small + } } } } diff --git a/gallery/qml/pages/IconsPage.qml b/gallery/qml/pages/IconsPage.qml index a9d56b6dd..999d34c26 100644 --- a/gallery/qml/pages/IconsPage.qml +++ b/gallery/qml/pages/IconsPage.qml @@ -76,6 +76,8 @@ ScrollView { GalleryComponents.IconLine { name: "morePhotosIcon"; source: __style.morePhotosIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked } GalleryComponents.IconLine { name: "plusIcon"; source: __style.plusIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked } GalleryComponents.IconLine { name: "positionTrackingIcon"; source: __style.positionTrackingIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked } + GalleryComponents.IconLine { name: "measurementToolIcon"; source: __style.measurementToolIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked } + GalleryComponents.IconLine { name: "closeShapeIcon"; source: __style.closeShapeIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked } GalleryComponents.IconLine { name: "qrCodeIcon"; source: __style.qrCodeIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked } GalleryComponents.IconLine { name: "satelliteIcon"; source: __style.satelliteIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked } GalleryComponents.IconLine { name: "searchIcon"; source: __style.searchIcon; showRect: checkboxBorder.checked; invertColors: checkboxColor.checked }