diff --git a/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in b/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in index e2055a6825e..a2934c3d8a8 100644 --- a/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in +++ b/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in @@ -23,7 +23,7 @@ settings. %End public: - QgsMapBoxGlStyleConverter( const QVariantMap &style ); + QgsMapBoxGlStyleConverter( const QVariantMap &style, const QString &styleName = QString() ); %Docstring Constructor for QgsMapBoxGlStyleConverter. @@ -53,7 +53,51 @@ or ``None`` if the style could not be converted successfully. protected: - void parseLayers( const QVariantList &layers ); + void parseLayers( const QVariantList &layers, const QString &styleName ); +%Docstring +Parse list of ``layers`` from JSON +%End + + static bool parseFillLayer( const QVariantMap &jsonLayer, const QString &styleName, QgsVectorTileBasicRendererStyle &style /Out/ ); +%Docstring +Parses a fill layer. + +:param jsonLayer: fill layer to parse +:param styleName: style name + +:return: - ``True`` if the layer was successfully parsed. + - style: generated QGIS vector tile style +%End + + static QgsProperty parseInterpolateColorByZoom( const QVariantMap &json ); + + static QColor parseColor( const QVariant &color ); +%Docstring +Parses a ``color`` in one of these supported formats: + +- #fff or #ffffff +- hsl(30, 19%, 90%) or hsla(30, 19%, 90%, 0.4) +- rgb(10, 20, 30) or rgba(10, 20, 30, 0.5) + +Returns an invalid color if the color could not be parsed. +%End + + static void colorAsHslaComponents( const QColor &color, int &hue, int &saturation, int &lightness, int &alpha ); +%Docstring +Takes a QColor object and returns HSLA components in required format for QGIS :py:func:`~QgsMapBoxGlStyleConverter.color_hsla` expression function. + +:param color: input color +:param hue: an integer value from 0 to 360 +:param saturation: an integer value from 0 to 100 +:param lightness: an integer value from 0 to 100 +:param alpha: an integer value from 0 (completely transparent) to 255 (opaque). +%End + + static QString interpolateExpression( int zoomMin, int zoomMax, double valueMin, double valueMax, double base ); +%Docstring +Generates an interpolation for values between ``valueMin`` and ``valueMax``, scaled between the +ranges ``zoomMin`` to ``zoomMax``. +%End private: QgsMapBoxGlStyleConverter( const QgsMapBoxGlStyleConverter &other ); diff --git a/src/core/vectortile/qgsmapboxglstyleconverter.cpp b/src/core/vectortile/qgsmapboxglstyleconverter.cpp index a6315446969..09360c0bc68 100644 --- a/src/core/vectortile/qgsmapboxglstyleconverter.cpp +++ b/src/core/vectortile/qgsmapboxglstyleconverter.cpp @@ -16,13 +16,17 @@ #include "qgsmapboxglstyleconverter.h" #include "qgsvectortilebasicrenderer.h" #include "qgsvectortilebasiclabeling.h" +#include "qgssymbollayer.h" +#include "qgssymbollayerutils.h" +#include "qgslogger.h" -QgsMapBoxGlStyleConverter::QgsMapBoxGlStyleConverter( const QVariantMap &style ) + +QgsMapBoxGlStyleConverter::QgsMapBoxGlStyleConverter( const QVariantMap &style, const QString &styleName ) : mStyle( style ) { if ( mStyle.contains( QStringLiteral( "layers" ) ) ) { - parseLayers( mStyle.value( QStringLiteral( "layers" ) ).toList() ); + parseLayers( mStyle.value( QStringLiteral( "layers" ) ).toList(), styleName ); } else { @@ -32,9 +36,254 @@ QgsMapBoxGlStyleConverter::QgsMapBoxGlStyleConverter( const QVariantMap &style ) QgsMapBoxGlStyleConverter::~QgsMapBoxGlStyleConverter() = default; -void QgsMapBoxGlStyleConverter::parseLayers( const QVariantList &layers ) +void QgsMapBoxGlStyleConverter::parseLayers( const QVariantList &layers, const QString &styleName ) { + QList rendererStyles; + QList labelingStyles; + for ( const QVariant &layer : layers ) + { + const QVariantMap jsonLayer = layer.toMap(); + + const QString layerType = jsonLayer.value( QStringLiteral( "type" ) ).toString(); + if ( layerType == QLatin1String( "background" ) ) + continue; + + const QString styleId = jsonLayer.value( QStringLiteral( "id" ) ).toString(); + const QString layerName = jsonLayer.value( QStringLiteral( "source-layer" ) ).toString(); + + const int minZoom = jsonLayer.value( QStringLiteral( "minzoom" ), QStringLiteral( "-1" ) ).toInt(); + const int maxZoom = jsonLayer.value( QStringLiteral( "maxzoom" ), QStringLiteral( "-1" ) ).toInt(); + + const bool enabled = jsonLayer.value( QStringLiteral( "visibility" ) ).toString() != QLatin1String( "none" ); + + QString filterExpression; + if ( jsonLayer.contains( QStringLiteral( "filter" ) ) ) + { + // filterExpression = parseExpression( jsonLayer.value( QStringLiteral( "filter" ) ) ); + } + + QgsVectorTileBasicRendererStyle rendererStyle; + QgsVectorTileBasicLabelingStyle labelingStyle; + + bool hasRendererStyle = false; + bool hasLabelingStyle = false; + if ( layerType == QLatin1String( "fill" ) ) + { + hasRendererStyle = parseFillLayer( jsonLayer, styleName, rendererStyle ); + } + else if ( layerType == QLatin1String( "line" ) ) + { + // hasRendererStyle = parseLineLayer( jsonLayer, styleName ); + } + else if ( layerType == QLatin1String( "symbol" ) ) + { + // hasLabelingStyle = parseSymbolLayer( jsonLayer, styleName ); + } + else + { + QgsDebugMsg( QStringLiteral( "Skipping unknown layer type: %1" ).arg( layerType ) ); + continue; + } + + if ( hasRendererStyle ) + { + rendererStyle.setStyleName( styleId ); + rendererStyle.setLayerName( layerName ); + rendererStyle.setFilterExpression( filterExpression ); + rendererStyle.setMinZoomLevel( minZoom ); + rendererStyle.setMaxZoomLevel( maxZoom ); + rendererStyle.setEnabled( enabled ); + rendererStyles.append( rendererStyle ); + } + + if ( hasLabelingStyle ) + { + labelingStyle.setStyleName( styleId ); + labelingStyle.setLayerName( layerName ); + labelingStyle.setFilterExpression( filterExpression ); + labelingStyle.setMinZoomLevel( minZoom ); + labelingStyle.setMaxZoomLevel( maxZoom ); + labelingStyle.setEnabled( enabled ); + labelingStyles.append( labelingStyle ); + } + + } + + mRenderer = qgis::make_unique< QgsVectorTileBasicRenderer >(); + QgsVectorTileBasicRenderer *renderer = dynamic_cast< QgsVectorTileBasicRenderer *>( mRenderer.get() ); + renderer->setStyles( rendererStyles ); + + mLabeling = qgis::make_unique< QgsVectorTileBasicLabeling >(); + QgsVectorTileBasicLabeling *labeling = dynamic_cast< QgsVectorTileBasicLabeling * >( mLabeling.get() ); + labeling->setStyles( labelingStyles ); +} + +bool QgsMapBoxGlStyleConverter::parseFillLayer( const QVariantMap &jsonLayer, const QString &styleName, QgsVectorTileBasicRendererStyle &style ) +{ + if ( !jsonLayer.contains( QStringLiteral( "paint" ) ) ) + { + QgsDebugMsg( QStringLiteral( "Style layer %1 has no paint property, skipping" ).arg( jsonLayer.value( QStringLiteral( "id" ) ).toString() ) ); + return false; + } + + const QVariantMap jsonPaint = jsonLayer.value( QStringLiteral( "paint" ) ).toMap(); + + QgsPropertyCollection ddProperties; + + // fill color + QColor fillColor; + if ( jsonLayer.contains( QStringLiteral( "fill-color" ) ) ) + { + const QVariant jsonFillColor = jsonPaint.value( QStringLiteral( "fill-color" ) ); + switch ( jsonFillColor.type() ) + { + case QVariant::Map: + ddProperties.setProperty( QgsSymbolLayer::PropertyFillColor, parseInterpolateColorByZoom( jsonFillColor.toMap() ) ); + break; + + case QVariant::List: + case QVariant::StringList: + + break; + + case QVariant::String: + + break; + + default: + break; + } + + } + + +} + +QgsProperty QgsMapBoxGlStyleConverter::parseInterpolateColorByZoom( const QVariantMap &json ) +{ + const double base = json.value( QStringLiteral( "base" ), QStringLiteral( "1" ) ).toDouble(); + const QVariantList stops = json.value( QStringLiteral( "stops" ) ).toList(); + if ( stops.empty() ) + return QgsProperty(); + + QString caseString = QStringLiteral( "CASE " ); + + if ( base == 1 ) + { + // base = 1 -> scale linear + for ( int i = 0; i < stops.length() - 1; ++i ) + { + // step bottom zoom + const QString bz = stops.at( i ).toList().value( 0 ).toString(); + // step top zoom + const QString tz = stops.at( i + 1 ).toList().value( 0 ).toString(); + + const QColor bottomColor = parseColor( stops.at( i ).toList().value( 1 ) ); + const QColor topColor = parseColor( stops.at( i + 1 ).toList().value( 1 ) ); + + int bcHue; + int bcSat; + int bcLight; + int bcAlpha; + colorAsHslaComponents( bottomColor, bcHue, bcSat, bcLight, bcAlpha ); + int tcHue; + int tcSat; + int tcLight; + int tcAlpha; + colorAsHslaComponents( topColor, tcHue, tcSat, tcLight, tcAlpha ); + + caseString += QStringLiteral( "WHEN @zoom_level >= %1 AND @zoom_level < %2 THEN color_hsla(" + "scale_linear(@zoom_level, %1, %2, %3, %4), " + "scale_linear(@zoom_level, %1, %2, %5, %6), " + "scale_linear(@zoom_level, %1, %2, %7, %8), " + "scale_linear(@zoom_level, %1, %2, %9, %10)) " + ).arg( bz, tz ) + .arg( bcHue ) + .arg( tcHue ) + .arg( bcSat ) + .arg( tcSat ) + .arg( bcLight ) + .arg( tcLight ) + .arg( bcAlpha ) + .arg( tcAlpha ); + } + } + else + { + // Base != 1 -> scale_exp() + for ( int i = 0; i < stops.length() - 1; ++i ) + { + // step bottom zoom + const QString bz = stops.at( i ).toList().value( 0 ).toString(); + // step top zoom + const QString tz = stops.at( i + 1 ).toList().value( 0 ).toString(); + + const QColor bottomColor = parseColor( stops.at( i ).toList().value( 1 ) ); + const QColor topColor = parseColor( stops.at( i + 1 ).toList().value( 1 ) ); + + int bcHue; + int bcSat; + int bcLight; + int bcAlpha; + colorAsHslaComponents( bottomColor, bcHue, bcSat, bcLight, bcAlpha ); + int tcHue; + int tcSat; + int tcLight; + int tcAlpha; + colorAsHslaComponents( topColor, tcHue, tcSat, tcLight, tcAlpha ); + + caseString += QStringLiteral( "WHEN @zoom_level >= %1 AND @zoom_level < %2 THEN color_hsla(" + "%3, %4, %5, %6) " ).arg( bz, tz, + interpolateExpression( bz.toInt(), tz.toInt(), bcHue, tcHue, base ), + interpolateExpression( bz.toInt(), tz.toInt(), bcSat, tcSat, base ), + interpolateExpression( bz.toInt(), tz.toInt(), bcLight, tcLight, base ), + interpolateExpression( bz.toInt(), tz.toInt(), bcAlpha, tcAlpha, base ) ); + } + } + + // top color + const QString tz = stops.last().toList().value( 0 ).toString(); + const QColor topColor = parseColor( stops.last().toList().value( 1 ) ); + int tcHue; + int tcSat; + int tcLight; + int tcAlpha; + colorAsHslaComponents( topColor, tcHue, tcSat, tcLight, tcAlpha ); + + caseString += QStringLiteral( "WHEN @zoom_level >= %1 THEN color_hsla(%2, %3, %4, %5) " + "ELSE color_hsla(%2, %3, %4, %5) END" ).arg( tz ) + .arg( tcHue ).arg( tcSat ).arg( tcLight ).arg( tcAlpha ); + + return QgsProperty::fromExpression( caseString ); +} + +QColor QgsMapBoxGlStyleConverter::parseColor( const QVariant &color ) +{ + if ( color.type() != QVariant::String ) + { + QgsDebugMsg( QStringLiteral( "Could not parse non-string color %1, skipping" ).arg( color.toString() ) ); + return QColor(); + } + + return QgsSymbolLayerUtils::parseColor( color.toString() ); +} + +void QgsMapBoxGlStyleConverter::colorAsHslaComponents( const QColor &color, int &hue, int &saturation, int &lightness, int &alpha ) +{ + hue = std::max( 0, color.hslHue() ); + saturation = color.hslSaturation() / 255.0 * 100; + lightness = color.lightness() / 255.0 * 100; + alpha = color.alpha(); +} + +QString QgsMapBoxGlStyleConverter::interpolateExpression( int zoomMin, int zoomMax, double valueMin, double valueMax, double base ) +{ + return QStringLiteral( "%1 + %2 * (%3^(@zoom_level-%4)-1)/(%3^(%5-%4)-1)" ).arg( valueMin ) + .arg( valueMax - valueMin ) + .arg( base ) + .arg( zoomMin ) + .arg( zoomMax ); } QgsVectorTileRenderer *QgsMapBoxGlStyleConverter::renderer() const diff --git a/src/core/vectortile/qgsmapboxglstyleconverter.h b/src/core/vectortile/qgsmapboxglstyleconverter.h index ed6c2b4aa81..40547b0a162 100644 --- a/src/core/vectortile/qgsmapboxglstyleconverter.h +++ b/src/core/vectortile/qgsmapboxglstyleconverter.h @@ -18,11 +18,13 @@ #include "qgis_core.h" #include "qgis_sip.h" +#include "qgsproperty.h" #include #include class QgsVectorTileRenderer; class QgsVectorTileLabeling; +class QgsVectorTileBasicRendererStyle; /** * \ingroup core @@ -40,7 +42,7 @@ class CORE_EXPORT QgsMapBoxGlStyleConverter * * The specified MapBox GL \a style configuration will be converted. */ - QgsMapBoxGlStyleConverter( const QVariantMap &style ); + QgsMapBoxGlStyleConverter( const QVariantMap &style, const QString &styleName = QString() ); //! QgsMapBoxGlStyleConverter cannot be copied QgsMapBoxGlStyleConverter( const QgsMapBoxGlStyleConverter &other ) = delete; @@ -69,7 +71,49 @@ class CORE_EXPORT QgsMapBoxGlStyleConverter protected: - void parseLayers( const QVariantList &layers ); + /** + * Parse list of \a layers from JSON + */ + void parseLayers( const QVariantList &layers, const QString &styleName ); + + /** + * Parses a fill layer. + * + * \param jsonLayer fill layer to parse + * \param styleName style name + * \param style generated QGIS vector tile style + * \returns TRUE if the layer was successfully parsed. + */ + static bool parseFillLayer( const QVariantMap &jsonLayer, const QString &styleName, QgsVectorTileBasicRendererStyle &style SIP_OUT ); + + static QgsProperty parseInterpolateColorByZoom( const QVariantMap &json ); + + /** + * Parses a \a color in one of these supported formats: + * + * - #fff or #ffffff + * - hsl(30, 19%, 90%) or hsla(30, 19%, 90%, 0.4) + * - rgb(10, 20, 30) or rgba(10, 20, 30, 0.5) + * + * Returns an invalid color if the color could not be parsed. + */ + static QColor parseColor( const QVariant &color ); + + /** + * Takes a QColor object and returns HSLA components in required format for QGIS color_hsla() expression function. + * \param color input color + * \param hue an integer value from 0 to 360 + * \param saturation an integer value from 0 to 100 + * \param lightness an integer value from 0 to 100 + * \param alpha an integer value from 0 (completely transparent) to 255 (opaque). + */ + static void colorAsHslaComponents( const QColor &color, int &hue, int &saturation, int &lightness, int &alpha ); + + /** + * Generates an interpolation for values between \a valueMin and \a valueMax, scaled between the + * ranges \a zoomMin to \a zoomMax. + */ + static QString interpolateExpression( int zoomMin, int zoomMax, double valueMin, double valueMax, double base ); private: @@ -78,7 +122,6 @@ class CORE_EXPORT QgsMapBoxGlStyleConverter #endif - QVariantMap mStyle; QString mError; diff --git a/tests/src/python/test_qgsmapboxglconverter.py b/tests/src/python/test_qgsmapboxglconverter.py index d1de6c745ff..771cdd793c7 100644 --- a/tests/src/python/test_qgsmapboxglconverter.py +++ b/tests/src/python/test_qgsmapboxglconverter.py @@ -44,12 +44,36 @@ TEST_DATA_DIR = unitTestDataPath() class TestQgsMapBoxGlStyleConverter(unittest.TestCase): + maxDiff = 100000 + def testNoLayer(self): c = QgsMapBoxGlStyleConverter({'x': 'y'}) self.assertEqual(c.errorMessage(), 'Could not find layers list in JSON') self.assertIsNone(c.renderer()) self.assertIsNone(c.labeling()) + def testInterpolateExpression(self): + self.assertEqual(QgsMapBoxGlStyleConverter.interpolateExpression(5, 13, 27, 29, 1), + '27 + 2 * (1^(@zoom_level-5)-1)/(1^(13-5)-1)') + self.assertEqual(QgsMapBoxGlStyleConverter.interpolateExpression(5, 13, 27, 29, 1.5), + '27 + 2 * (1.5^(@zoom_level-5)-1)/(1.5^(13-5)-1)') + + def testColorAsHslaComponents(self): + self.assertEqual(QgsMapBoxGlStyleConverter.colorAsHslaComponents(QColor.fromHsl(30, 50, 70)), (30, 19, 27, 255)) + + def testParseInterpolateColorByZoom(self): + self.assertEqual(QgsMapBoxGlStyleConverter.parseInterpolateColorByZoom({}).isActive(), False) + self.assertEqual(QgsMapBoxGlStyleConverter.parseInterpolateColorByZoom({'base': 1, + 'stops': [[0, '#f1f075'], + [150, '#b52e3e'], + [250, '#e55e5e']] + }).expressionString(), 'CASE WHEN @zoom_level >= 0 AND @zoom_level < 150 THEN color_hsla(scale_linear(@zoom_level, 0, 150, 59, 352), scale_linear(@zoom_level, 0, 150, 81, 59), scale_linear(@zoom_level, 0, 150, 70, 44), scale_linear(@zoom_level, 0, 150, 255, 255)) WHEN @zoom_level >= 150 AND @zoom_level < 250 THEN color_hsla(scale_linear(@zoom_level, 150, 250, 352, 0), scale_linear(@zoom_level, 150, 250, 59, 72), scale_linear(@zoom_level, 150, 250, 44, 63), scale_linear(@zoom_level, 150, 250, 255, 255)) WHEN @zoom_level >= 250 THEN color_hsla(0, 72, 63, 255) ELSE color_hsla(0, 72, 63, 255) END') + self.assertEqual(QgsMapBoxGlStyleConverter.parseInterpolateColorByZoom({'base': 2, + 'stops': [[0, '#f1f075'], + [150, '#b52e3e'], + [250, '#e55e5e']] + }).expressionString(), 'CASE WHEN @zoom_level >= 0 AND @zoom_level < 150 THEN color_hsla(59 + 293 * (2^(@zoom_level-0)-1)/(2^(150-0)-1), 81 + -22 * (2^(@zoom_level-0)-1)/(2^(150-0)-1), 70 + -26 * (2^(@zoom_level-0)-1)/(2^(150-0)-1), 255 + 0 * (2^(@zoom_level-0)-1)/(2^(150-0)-1)) WHEN @zoom_level >= 150 AND @zoom_level < 250 THEN color_hsla(352 + -352 * (2^(@zoom_level-150)-1)/(2^(250-150)-1), 59 + 13 * (2^(@zoom_level-150)-1)/(2^(250-150)-1), 44 + 19 * (2^(@zoom_level-150)-1)/(2^(250-150)-1), 255 + 0 * (2^(@zoom_level-150)-1)/(2^(250-150)-1)) WHEN @zoom_level >= 250 THEN color_hsla(0, 72, 63, 255) ELSE color_hsla(0, 72, 63, 255) END') + if __name__ == '__main__': unittest.main()