diff --git a/include/mbgl/style/sources/geojson_source.hpp b/include/mbgl/style/sources/geojson_source.hpp index fe3524988..0958ebd78 100644 --- a/include/mbgl/style/sources/geojson_source.hpp +++ b/include/mbgl/style/sources/geojson_source.hpp @@ -29,6 +29,7 @@ struct GeoJSONOptions { bool cluster = false; uint16_t clusterRadius = 50; uint8_t clusterMaxZoom = 17; + size_t clusterMinPoints = 2; using ClusterExpression = std::pair, std::shared_ptr>; using ClusterProperties = std::map; diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonOptions.kt b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonOptions.kt index bbe8466fd..773fb3311 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonOptions.kt +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonOptions.kt @@ -100,6 +100,17 @@ class GeoJsonOptions : HashMap() { return this } + /** + * Minimum number of points to form a cluster + * + * @param clusterMinPoints minimum number of points to form a cluster - Defaults to 2 + * @return the current instance for chaining + */ + fun withClusterMinPoints(clusterMinPoints: Int): GeoJsonOptions { + this["clusterMinPoints"] = clusterMinPoints + return this + } + /** * An object defining custom properties on the generated clusters if clustering is enabled, * aggregating values from clustered points. Has the form {"property_name": [operator, [map_expression]]} or diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/style/GeoJsonClusteringActivity.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/style/GeoJsonClusteringActivity.kt index c95745646..4b77d9a93 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/style/GeoJsonClusteringActivity.kt +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/style/GeoJsonClusteringActivity.kt @@ -139,6 +139,7 @@ class GeoJsonClusteringActivity : AppCompatActivity() { .withCluster(true) .withClusterMaxZoom(14) .withClusterRadius(50) + .withClusterMinPoints(3) .withClusterProperty( "max", Expression.max(Expression.accumulated(), Expression.get("max")), diff --git a/platform/darwin/src/MLNShapeSource.h b/platform/darwin/src/MLNShapeSource.h index c5e981eb2..2b6188ac8 100644 --- a/platform/darwin/src/MLNShapeSource.h +++ b/platform/darwin/src/MLNShapeSource.h @@ -44,6 +44,15 @@ FOUNDATION_EXTERN MLN_EXPORT const MLNShapeSourceOption MLNShapeSourceOptionClus */ FOUNDATION_EXTERN MLN_EXPORT const MLNShapeSourceOption MLNShapeSourceOptionClusterRadius; +/** + An `NSNumber` object containing an integer; specifies the minimum number of points to form a + cluster if clustering is enabled. The default value is 2. + + This option only affects point features within an ``MLNShapeSource`` object; it + is ignored when creating an ``MLNComputedShapeSource`` object. + */ +FOUNDATION_EXTERN MLN_EXPORT const MLNShapeSourceOption MLNShapeSourceOptionClusterMinPoints; + /** An `NSDictionary` object where the key is an `NSString`. The dictionary key will be the feature attribute key. The resulting attribute value is @@ -233,7 +242,7 @@ MLN_EXPORT for the source. This class supports the following options: ``MLNShapeSourceOptionClustered``, - ``MLNShapeSourceOptionClusterRadius``, + ``MLNShapeSourceOptionClusterRadius``, ``MLNShapeSourceOptionClusterMinPoints``, ``MLNShapeSourceOptionMaximumZoomLevelForClustering``, ``MLNShapeSourceOptionMinimumZoomLevel``, ``MLNShapeSourceOptionMinimumZoomLevel``, ``MLNShapeSourceOptionBuffer``, and diff --git a/platform/darwin/src/MLNShapeSource.mm b/platform/darwin/src/MLNShapeSource.mm index d207ace48..d93442013 100644 --- a/platform/darwin/src/MLNShapeSource.mm +++ b/platform/darwin/src/MLNShapeSource.mm @@ -19,6 +19,7 @@ const MLNShapeSourceOption MLNShapeSourceOptionBuffer = @"MLNShapeSourceOptionBuffer"; const MLNShapeSourceOption MLNShapeSourceOptionClusterRadius = @"MLNShapeSourceOptionClusterRadius"; +const MLNShapeSourceOption MLNShapeSourceOptionClusterMinPoints = @"MLNShapeSourceOptionClusterMinPoints"; const MLNShapeSourceOption MLNShapeSourceOptionClustered = @"MLNShapeSourceOptionClustered"; const MLNShapeSourceOption MLNShapeSourceOptionClusterProperties = @"MLNShapeSourceOptionClusterProperties"; const MLNShapeSourceOption MLNShapeSourceOptionMaximumZoomLevel = @"MLNShapeSourceOptionMaximumZoomLevel"; @@ -70,6 +71,14 @@ mbgl::Immutable MLNGeoJSONOptionsFromDictionary(NSD geoJSONOptions->clusterRadius = value.integerValue; } + if (NSNumber *value = options[MLNShapeSourceOptionClusterMinPoints]) { + if (![value isKindOfClass:[NSNumber class]]) { + [NSException raise:NSInvalidArgumentException + format:@"MLNShapeSourceOptionClusterMinPoints must be an NSNumber."]; + } + geoJSONOptions->clusterMinPoints = value.integerValue; + } + if (NSNumber *value = options[MLNShapeSourceOptionMaximumZoomLevelForClustering]) { if (![value isKindOfClass:[NSNumber class]]) { [NSException raise:NSInvalidArgumentException diff --git a/platform/darwin/test/MLNShapeSourceTests.mm b/platform/darwin/test/MLNShapeSourceTests.mm index 5c7d95536..4dd18ed61 100644 --- a/platform/darwin/test/MLNShapeSourceTests.mm +++ b/platform/darwin/test/MLNShapeSourceTests.mm @@ -18,6 +18,7 @@ NSArray *clusterPropertyArray = @[reduceExpression, mapExpression]; NSDictionary *options = @{MLNShapeSourceOptionClustered: @YES, MLNShapeSourceOptionClusterRadius: @42, + MLNShapeSourceOptionClusterMinPoints: @3, MLNShapeSourceOptionClusterProperties: @{@"sumValue": clusterPropertyArray}, MLNShapeSourceOptionMaximumZoomLevelForClustering: @98, MLNShapeSourceOptionMaximumZoomLevel: @99, @@ -29,6 +30,7 @@ XCTAssertTrue(mbglOptions->cluster); XCTAssertEqual(mbglOptions->clusterRadius, 42); XCTAssertEqual(mbglOptions->clusterMaxZoom, 98); + XCTAssertEqual(mbglOptions->clusterMinPoints, 3UL); XCTAssertEqual(mbglOptions->maxzoom, 99); XCTAssertEqual(mbglOptions->buffer, 1976); XCTAssertEqual(mbglOptions->tolerance, 0.42); diff --git a/platform/ios/MapLibre.docc/For_Style_Authors.md b/platform/ios/MapLibre.docc/For_Style_Authors.md index 966727762..3d996e3e0 100644 --- a/platform/ios/MapLibre.docc/For_Style_Authors.md +++ b/platform/ios/MapLibre.docc/For_Style_Authors.md @@ -150,6 +150,7 @@ In style JSON | In the SDK `tolerance` | `MLNShapeSourceOptionSimplificationTolerance` `cluster` | `MLNShapeSourceOptionClustered` `clusterRadius` | `MLNShapeSourceOptionClusterRadius` +`clusterMinPoints` | `MLNShapeSourceOptionClusterMinPoints` `clusterMaxZoom` | `MLNShapeSourceOptionMaximumZoomLevelForClustering` `lineMetrics` | `MLNShapeSourceOptionLineDistanceMetrics` diff --git a/src/mbgl/style/conversion/geojson_options.cpp b/src/mbgl/style/conversion/geojson_options.cpp index 7a7ceb97d..f6876143a 100644 --- a/src/mbgl/style/conversion/geojson_options.cpp +++ b/src/mbgl/style/conversion/geojson_options.cpp @@ -81,6 +81,16 @@ std::optional Converter::operator()(const Conver } } + const auto clusterMinPointsValue = objectMember(value, "clusterMinPoints"); + if (clusterMinPointsValue) { + if (toNumber(*clusterMinPointsValue)) { + options.clusterMinPoints = static_cast(*toNumber(*clusterMinPointsValue)); + } else { + error.message = "GeoJSON source clusterMinPoints value must be a number"; + return std::nullopt; + } + } + const auto lineMetricsValue = objectMember(value, "lineMetrics"); if (lineMetricsValue) { if (toBool(*lineMetricsValue)) { diff --git a/src/mbgl/style/sources/geojson_source_impl.cpp b/src/mbgl/style/sources/geojson_source_impl.cpp index ae81f0855..db6469ee4 100644 --- a/src/mbgl/style/sources/geojson_source_impl.cpp +++ b/src/mbgl/style/sources/geojson_source_impl.cpp @@ -99,6 +99,8 @@ std::shared_ptr GeoJSONData::create(const GeoJSON& geoJSON, clusterOptions.maxZoom = options->clusterMaxZoom; clusterOptions.extent = util::EXTENT; clusterOptions.radius = static_cast(::round(scale * options->clusterRadius)); + clusterOptions.minPoints = options->clusterMinPoints; + auto feature = std::make_shared(); clusterOptions.map = [feature, options](const PropertyMap& properties) -> PropertyMap { PropertyMap ret{}; diff --git a/test/style/conversion/geojson_options.test.cpp b/test/style/conversion/geojson_options.test.cpp index 0832255dc..192d30f73 100644 --- a/test/style/conversion/geojson_options.test.cpp +++ b/test/style/conversion/geojson_options.test.cpp @@ -40,6 +40,7 @@ TEST(GeoJSONOptions, RetainsDefaults) { ASSERT_EQ(converted.cluster, defaults.cluster); ASSERT_EQ(converted.clusterRadius, defaults.clusterRadius); ASSERT_EQ(converted.clusterMaxZoom, defaults.clusterMaxZoom); + ASSERT_EQ(converted.clusterMinPoints, defaults.clusterMinPoints); ASSERT_TRUE(converted.clusterProperties.empty()); } @@ -53,6 +54,7 @@ TEST(GeoJSONOptions, FullConversion) { "cluster": true, "clusterRadius": 4, "clusterMaxZoom": 5, + "clusterMinPoints": 6, "lineMetrics": true, "clusterProperties": { "max": ["max", ["get", "scalerank"]], @@ -73,6 +75,7 @@ TEST(GeoJSONOptions, FullConversion) { ASSERT_EQ(converted.cluster, true); ASSERT_EQ(converted.clusterRadius, 4); ASSERT_EQ(converted.clusterMaxZoom, 5); + ASSERT_EQ(converted.clusterMinPoints, 6); ASSERT_EQ(converted.clusterProperties.size(), 3); ASSERT_EQ(converted.clusterProperties.count("max"), 1); ASSERT_EQ(converted.clusterProperties.count("sum"), 1);