From d1a8471c08139562bdcfbd649fd0fd440615a568 Mon Sep 17 00:00:00 2001 From: NithyaNN3 Date: Sat, 24 May 2025 23:05:43 +1000 Subject: [PATCH 1/6] sorted imports for clone and dbscan --- lib/clone.dart | 4 ++++ lib/dbscan.dart | 4 ++++ lib/src/clone.dart | 0 lib/src/dbscan.dart | 6 ++++++ lib/turf.dart | 2 ++ 5 files changed, 16 insertions(+) create mode 100644 lib/clone.dart create mode 100644 lib/dbscan.dart create mode 100644 lib/src/clone.dart create mode 100644 lib/src/dbscan.dart diff --git a/lib/clone.dart b/lib/clone.dart new file mode 100644 index 00000000..46e15795 --- /dev/null +++ b/lib/clone.dart @@ -0,0 +1,4 @@ +library turf_clone; + +export 'package:geotypes/geotypes.dart'; +export 'src/clone.dart'; \ No newline at end of file diff --git a/lib/dbscan.dart b/lib/dbscan.dart new file mode 100644 index 00000000..6753d46d --- /dev/null +++ b/lib/dbscan.dart @@ -0,0 +1,4 @@ +library turf_dbscan; + +import 'package:geotypes/geotypes.dart'; +import 'src/dbscan.dart'; diff --git a/lib/src/clone.dart b/lib/src/clone.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/src/dbscan.dart b/lib/src/dbscan.dart new file mode 100644 index 00000000..379974d9 --- /dev/null +++ b/lib/src/dbscan.dart @@ -0,0 +1,6 @@ +import 'package:geotypes/geotypes.dart'; +import 'package:turf/clone.dart'; +import 'package:turf/distance.dart'; +import 'package:turf/helpers.dart'; +import 'package:rbush/rbush.dart'; + diff --git a/lib/turf.dart b/lib/turf.dart index 1ee09fcc..64616916 100644 --- a/lib/turf.dart +++ b/lib/turf.dart @@ -10,7 +10,9 @@ export 'boolean.dart'; export 'center.dart'; export 'centroid.dart'; export 'clean_coords.dart'; +export 'clone.dart'; export 'clusters.dart'; +export 'dbscan.dart'; export 'destination.dart'; export 'distance.dart'; export 'explode.dart'; From 6e459027b968e5aa9d458fa0017748e8fd98b70e Mon Sep 17 00:00:00 2001 From: NithyaNN3 Date: Sun, 25 May 2025 21:07:48 +1000 Subject: [PATCH 2/6] added dbscan code --- lib/src/dbscan.dart | 199 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/lib/src/dbscan.dart b/lib/src/dbscan.dart index 379974d9..da3f9a89 100644 --- a/lib/src/dbscan.dart +++ b/lib/src/dbscan.dart @@ -3,4 +3,203 @@ import 'package:turf/clone.dart'; import 'package:turf/distance.dart'; import 'package:turf/helpers.dart'; import 'package:rbush/rbush.dart'; +import 'dart:math' as math; +enum Dbscan { + core, + edge, + noise, +} + +class DbscanProps extends Properties { + Dbscan? dbscan; + int? cluster; + + DbscanProps({this.dbscan, this.cluster, super.properties}); + + factory DbscanProps.fromJson(Map json) { + return DbscanProps( + dbscan: Dbscan.values.byName(json['dbscan']), + cluster: json['cluster'] as int?, + properties: json, + ); + } + + Map toJson() { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('dbscan', dbscan?.name); + writeNotNull('cluster', cluster); + if (properties != null) { + val.addAll(properties!); + } + return val; + } +} + +class IndexedPoint implements HasExtent { + double minX; + double minY; + double maxX; + double maxY; + int index; + + IndexedPoint({ + required this.minX, + required this.minY, + required this.maxX, + required this.maxY, + required this.index, + }); + + @override + Extent get extent => Extent(minX, minY, maxX, maxY); +} + +FeatureCollection clustersDbscan( + FeatureCollection points, + double maxDistance, { + Units units = Units.kilometers, + bool mutate = false, + int minPoints = 3, +}) { + // Input validation (Dart's type system helps here, but we can add more runtime checks if needed) + if (maxDistance <= 0) { + throw ArgumentError('maxDistance must be greater than 0'); + } + if (minPoints <= 0) { + throw ArgumentError('minPoints must be greater than 0'); + } + + // Clone points to prevent mutations + FeatureCollection processedPoints = + mutate ? points : clone(points) as FeatureCollection; + + // Calculate the distance in degrees for region queries + final double latDistanceInDegrees = lengthToDegrees(maxDistance, unit: units); + + // Create a spatial index + final RBush tree = RBush(maxEntries: points.features.length); + + // Keeps track of whether a point has been visited or not. + final List visited = List.filled(processedPoints.features.length, false); + + // Keeps track of whether a point is assigned to a cluster or not. + final List assigned = List.filled(processedPoints.features.length, false); + + // Keeps track of whether a point is noise|edge or not. + final List isnoise = List.filled(processedPoints.features.length, false); + + // Keeps track of the clusterId for each point + final List clusterIds = List.filled(processedPoints.features.length, -1); + + // Index each point for spatial queries + tree.load( + processedPoints.features.asMap().entries.map((entry) { + final int index = entry.key; + final Point point = entry.value; + final List coordinates = point.coordinates as List; + final double x = coordinates[0]; + final double y = coordinates[1]; + return IndexedPoint( + minX: x, + minY: y, + maxX: x, + maxY: y, + index: index, + ); + }).toList(), + ); + + // Function to find neighbors of a point within a given distance + List regionQuery(int index) { + final Point point = processedPoints.features[index]; + final List coordinates = point.coordinates as List; + final double x = coordinates[0]; + final double y = coordinates[1]; + + final double minY = math.max(y - latDistanceInDegrees, -90.0); + final double maxY = math.min(y + latDistanceInDegrees, 90.0); + + double lonDistanceInDegrees = () { + // Handle the case where the bounding box crosses the poles + if (minY < 0 && maxY > 0) { + return latDistanceInDegrees; + } + if (minY.abs() < maxY.abs()) { + return latDistanceInDegrees / math.cos(degreesToRadians(maxY)); + } else { + return latDistanceInDegrees / math.cos(degreesToRadians(minY)); + } + }(); + + final double minX = math.max(x - lonDistanceInDegrees, -360.0); + final double maxX = math.min(x + lonDistanceInDegrees, 360.0); + + // Calculate the bounding box for the region query + final Extent bbox = Extent(minX, minY, maxX, maxY); + return tree.search(bbox).where((neighbor) { + final int neighborIndex = neighbor.index; + final Point neighborPoint = processedPoints.features[neighborIndex]; + final double distanceInKm = distance(point, neighborPoint, units: Units.kilometers); + return distanceInKm <= maxDistance; + }).toList(); + } + + // Function to expand a cluster + void expandCluster(int clusteredId, List neighbors) { + for (int i = 0; i < neighbors.length; i++) { + final IndexedPoint neighbor = neighbors[i]; + final int neighborIndex = neighbor.index; + if (!visited[neighborIndex]) { + visited[neighborIndex] = true; + final List nextNeighbors = regionQuery(neighborIndex); + if (nextNeighbors.length >= minPoints) { + neighbors.addAll(nextNeighbors); + } + } + if (!assigned[neighborIndex]) { + assigned[neighborIndex] = true; + clusterIds[neighborIndex] = clusteredId; + } + } + } + + // Main DBSCAN clustering algorithm + int nextClusteredId = 0; + processedPoints.features.asMap().forEach((index, _) { + if (visited[index]) return; + final List neighbors = regionQuery(index); + if (neighbors.length >= minPoints) { + final int clusteredId = nextClusteredId++; + visited[index] = true; + expandCluster(clusteredId, neighbors); + } else { + isnoise[index] = true; + } + }); + + // Assign DBSCAN properties to each point + final List> clusteredFeatures = + processedPoints.features.asMap().entries.map((entry) { + final int index = entry.key; + final Point clusterPoint = entry.value; + final DbscanProps properties = DbscanProps(); + + if (clusterIds[index] >= 0) { + properties.dbscan = isnoise[index] ? Dbscan.edge : Dbscan.core; + properties.cluster = clusterIds[index]; + } else { + properties.dbscan = Dbscan.noise; + } + return Feature(geometry: clusterPoint, properties: properties); + }).toList(); + + return FeatureCollection(features: clusteredFeatures); +} From 1f53c8a92678cfdba3a5a55d37a09ad61f8eae76 Mon Sep 17 00:00:00 2001 From: NithyaNN3 Date: Sun, 1 Jun 2025 15:31:11 +1000 Subject: [PATCH 3/6] Clone code + tests --- lib/src/clone.dart | 106 +++++++++++++ lib/src/dbscan.dart | 268 +++++++++++--------------------- test/components/clone_test.dart | 112 +++++++++++++ 3 files changed, 309 insertions(+), 177 deletions(-) create mode 100644 test/components/clone_test.dart diff --git a/lib/src/clone.dart b/lib/src/clone.dart index e69de29b..90a41617 100644 --- a/lib/src/clone.dart +++ b/lib/src/clone.dart @@ -0,0 +1,106 @@ + +// Deep clone any GeoJSON object: FeatureCollection, Feature, Geometry, and Properties. + +dynamic clone(dynamic geojson) { + if (geojson == null) { + throw ArgumentError('geojson is required'); + } + + switch (geojson['type']) { + case 'Feature': + return cloneFeature(geojson); + case 'FeatureCollection': + return cloneFeatureCollection(geojson); + case 'Point': + case 'LineString': + case 'Polygon': + case 'MultiPoint': + case 'MultiLineString': + case 'MultiPolygon': + case 'GeometryCollection': + return cloneGeometry(geojson); + default: + throw ArgumentError('unknown GeoJSON type'); + } +} + +Map cloneFeature(Map geojson) { + final cloned = {'type': 'Feature'}; + + // Preserve foreign members + geojson.forEach((key, value) { + if (key != 'type' && key != 'properties' && key != 'geometry') { + cloned[key] = value; + } + }); + + cloned['properties'] = cloneProperties(geojson['properties']); + cloned['geometry'] = geojson['geometry'] == null + ? null + : cloneGeometry(geojson['geometry']); + + return cloned; +} + +dynamic cloneProperties(dynamic properties) { + if (properties == null) return {}; + + final cloned = {}; + + (properties as Map).forEach((key, value) { + if (value is Map) { + cloned[key] = cloneProperties(value); + } else if (value is List) { + cloned[key] = List.from(value); + } else { + cloned[key] = value; + } + }); + + return cloned; +} + +Map cloneFeatureCollection(Map geojson) { + final cloned = {'type': 'FeatureCollection'}; + + // Preserve foreign members + geojson.forEach((key, value) { + if (key != 'type' && key != 'features') { + cloned[key] = value; + } + }); + + cloned['features'] = + (geojson['features'] as List).map((f) => cloneFeature(f)).toList(); + + return cloned; +} + +Map cloneGeometry(Map geometry) { + final geom = {'type': geometry['type']}; + + if (geometry.containsKey('bbox')) { + geom['bbox'] = List.from(geometry['bbox']); + } + + if (geometry['type'] == 'GeometryCollection') { + geom['geometries'] = (geometry['geometries'] as List) + .map((g) => cloneGeometry(g)) + .toList(); + } else { + geom['coordinates'] = deepSlice(geometry['coordinates']); + } + + return geom; +} + +dynamic deepSlice(dynamic coords) { + if (coords is List) { + if (coords.isEmpty || coords[0] is! List) { + return List.from(coords); + } else { + return coords.map((c) => deepSlice(c)).toList(); + } + } + return coords; +} diff --git a/lib/src/dbscan.dart b/lib/src/dbscan.dart index da3f9a89..c742e4b0 100644 --- a/lib/src/dbscan.dart +++ b/lib/src/dbscan.dart @@ -5,201 +5,115 @@ import 'package:turf/helpers.dart'; import 'package:rbush/rbush.dart'; import 'dart:math' as math; -enum Dbscan { - core, - edge, - noise, -} - -class DbscanProps extends Properties { - Dbscan? dbscan; - int? cluster; - - DbscanProps({this.dbscan, this.cluster, super.properties}); - - factory DbscanProps.fromJson(Map json) { - return DbscanProps( - dbscan: Dbscan.values.byName(json['dbscan']), - cluster: json['cluster'] as int?, - properties: json, - ); - } - - Map toJson() { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('dbscan', dbscan?.name); - writeNotNull('cluster', cluster); - if (properties != null) { - val.addAll(properties!); - } - return val; - } -} - -class IndexedPoint implements HasExtent { - double minX; - double minY; - double maxX; - double maxY; - int index; - - IndexedPoint({ - required this.minX, - required this.minY, - required this.maxX, - required this.maxY, - required this.index, - }); - - @override - Extent get extent => Extent(minX, minY, maxX, maxY); -} - -FeatureCollection clustersDbscan( +/// DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is a data clustering algorithm. +/// Given a set of points in some space, it groups together points that are closely packed together +/// (points with many nearby neighbors), marking as outliers points that lie alone in low-density regions. +/// +/// @param {FeatureCollection} points Input points +/// @param {number} maxClusterLength Maximum number of points in a cluster +/// @param {number} minPoints Minimum number of points to form a cluster +/// @param {number} maxRadius Maximum radius between two points to be considered in the same neighborhood +/// @param {boolean} [mutableInput=true] allows GeoJSON input to be mutated (significant performance increase if true) +/// @returns {FeatureCollection} Clustered Points with an additional `cluster` property to each Point +FeatureCollection dbscan( FeatureCollection points, - double maxDistance, { - Units units = Units.kilometers, - bool mutate = false, - int minPoints = 3, + int maxClusterLength, + int minPoints, + double maxRadius, { + bool mutableInput = true, }) { - // Input validation (Dart's type system helps here, but we can add more runtime checks if needed) - if (maxDistance <= 0) { - throw ArgumentError('maxDistance must be greater than 0'); - } if (minPoints <= 0) { throw ArgumentError('minPoints must be greater than 0'); } + if (maxRadius < 0) { + throw ArgumentError('maxRadius must be greater than or equal to 0'); + } - // Clone points to prevent mutations - FeatureCollection processedPoints = - mutate ? points : clone(points) as FeatureCollection; - - // Calculate the distance in degrees for region queries - final double latDistanceInDegrees = lengthToDegrees(maxDistance, unit: units); - - // Create a spatial index - final RBush tree = RBush(maxEntries: points.features.length); - - // Keeps track of whether a point has been visited or not. - final List visited = List.filled(processedPoints.features.length, false); - - // Keeps track of whether a point is assigned to a cluster or not. - final List assigned = List.filled(processedPoints.features.length, false); - - // Keeps track of whether a point is noise|edge or not. - final List isnoise = List.filled(processedPoints.features.length, false); - - // Keeps track of the clusterId for each point - final List clusterIds = List.filled(processedPoints.features.length, -1); - - // Index each point for spatial queries - tree.load( - processedPoints.features.asMap().entries.map((entry) { - final int index = entry.key; - final Point point = entry.value; - final List coordinates = point.coordinates as List; - final double x = coordinates[0]; - final double y = coordinates[1]; - return IndexedPoint( - minX: x, - minY: y, - maxX: x, - maxY: y, - index: index, - ); - }).toList(), - ); - - // Function to find neighbors of a point within a given distance - List regionQuery(int index) { - final Point point = processedPoints.features[index]; - final List coordinates = point.coordinates as List; - final double x = coordinates[0]; - final double y = coordinates[1]; - - final double minY = math.max(y - latDistanceInDegrees, -90.0); - final double maxY = math.min(y + latDistanceInDegrees, 90.0); + final numberOfPoints = points.features.length; + final clustered = mutableInput ? points : clone(points); + final visited = List.filled(numberOfPoints, false); + final noise = List.filled(numberOfPoints, false); + int clusterId = 0; + + // Build an R-tree index for efficient neighbor searching + final tree = RBush>(maxEntries: 9); + for (int i = 0; i < numberOfPoints; i++) { + final feature = clustered.features[i]; + if (feature.geometry != null) { + tree.insert(feature.bbox!, feature); + } + } - double lonDistanceInDegrees = () { - // Handle the case where the bounding box crosses the poles - if (minY < 0 && maxY > 0) { - return latDistanceInDegrees; - } - if (minY.abs() < maxY.abs()) { - return latDistanceInDegrees / math.cos(degreesToRadians(maxY)); - } else { - return latDistanceInDegrees / math.cos(degreesToRadians(minY)); + // Function to find neighbors within a given radius + List getNeighbors(int pointIndex) { + final neighbors = []; + final targetPoint = clustered.features[pointIndex]; + if (targetPoint.geometry == null) { + return neighbors; + } + final envelope = [ + targetPoint.bbox![0] - maxRadius, + targetPoint.bbox![1] - maxRadius, + targetPoint.bbox![2] + maxRadius, + targetPoint.bbox![3] + maxRadius, + ]; + + final potentialNeighbors = tree.search(envelope); + for (final neighborFeature in potentialNeighbors) { + final neighborIndex = clustered.features.indexOf(neighborFeature); + if (pointIndex != neighborIndex) { + final distanceInMeters = distance(targetPoint, neighborFeature); + if (distanceInMeters <= maxRadius) { + neighbors.add(neighborIndex); + } } - }(); - - final double minX = math.max(x - lonDistanceInDegrees, -360.0); - final double maxX = math.min(x + lonDistanceInDegrees, 360.0); - - // Calculate the bounding box for the region query - final Extent bbox = Extent(minX, minY, maxX, maxY); - return tree.search(bbox).where((neighbor) { - final int neighborIndex = neighbor.index; - final Point neighborPoint = processedPoints.features[neighborIndex]; - final double distanceInKm = distance(point, neighborPoint, units: Units.kilometers); - return distanceInKm <= maxDistance; - }).toList(); + } + return neighbors; } - // Function to expand a cluster - void expandCluster(int clusteredId, List neighbors) { - for (int i = 0; i < neighbors.length; i++) { - final IndexedPoint neighbor = neighbors[i]; - final int neighborIndex = neighbor.index; + // Expand the cluster recursively + void expandCluster(int pointIndex, List neighbors) { + visited[pointIndex] = true; + clustered.features[pointIndex].properties['cluster'] = clusterId; + + int i = 0; + while (i < neighbors.length) { + final neighborIndex = neighbors[i]; if (!visited[neighborIndex]) { visited[neighborIndex] = true; - final List nextNeighbors = regionQuery(neighborIndex); - if (nextNeighbors.length >= minPoints) { - neighbors.addAll(nextNeighbors); + clustered.features[neighborIndex].properties['cluster'] = clusterId; + final newNeighbors = getNeighbors(neighborIndex); + if (newNeighbors.length >= minPoints) { + neighbors.addAll(newNeighbors.where((n) => !neighbors.contains(n))); } } - if (!assigned[neighborIndex]) { - assigned[neighborIndex] = true; - clusterIds[neighborIndex] = clusteredId; - } + i++; } } - // Main DBSCAN clustering algorithm - int nextClusteredId = 0; - processedPoints.features.asMap().forEach((index, _) { - if (visited[index]) return; - final List neighbors = regionQuery(index); - if (neighbors.length >= minPoints) { - final int clusteredId = nextClusteredId++; - visited[index] = true; - expandCluster(clusteredId, neighbors); - } else { - isnoise[index] = true; + // Iterate through each point + for (int i = 0; i < numberOfPoints; i++) { + if (!visited[i]) { + final neighbors = getNeighbors(i); + if (neighbors.length < minPoints) { + noise[i] = true; + } else { + expandCluster(i, neighbors); + clusterId++; + if (clusterId > maxClusterLength) { + throw ArgumentError( + 'Cluster exceeded maxClusterLength ($maxClusterLength)'); + } + } } - }); - - // Assign DBSCAN properties to each point - final List> clusteredFeatures = - processedPoints.features.asMap().entries.map((entry) { - final int index = entry.key; - final Point clusterPoint = entry.value; - final DbscanProps properties = DbscanProps(); + } - if (clusterIds[index] >= 0) { - properties.dbscan = isnoise[index] ? Dbscan.edge : Dbscan.core; - properties.cluster = clusterIds[index]; - } else { - properties.dbscan = Dbscan.noise; + // Add the 'cluster' property to noise points (set to null or -1) + for (int i = 0; i < numberOfPoints; i++) { + if (noise[i]) { + clustered.features[i].properties['cluster'] = null; // Or -1 } - return Feature(geometry: clusterPoint, properties: properties); - }).toList(); + } - return FeatureCollection(features: clusteredFeatures); -} + return clustered; +} \ No newline at end of file diff --git a/test/components/clone_test.dart b/test/components/clone_test.dart new file mode 100644 index 00000000..d46776fe --- /dev/null +++ b/test/components/clone_test.dart @@ -0,0 +1,112 @@ +import 'package:test/test.dart'; +import 'package:turf/clone.dart'; // Adjust path to where your `clone` function lives + +void main() { + group('GeoJSON clone tests', () { + test('Clones a simple Point feature', () { + final input = { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [102.0, 0.5] + }, + "properties": {"prop0": "value0"} + }; + + final result = clone(input); + + expect(result, equals(input)); + expect(identical(result, input), isFalse); // Ensure it's a deep clone + }); + + test('Clones a LineString feature with properties', () { + final input = { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [102.0, 0.0], + [103.0, 1.0], + [104.0, 0.0], + [105.0, 1.0] + ] + }, + "properties": {"stroke": "blue", "opacity": 0.6} + }; + + final result = clone(input); + + expect(result, equals(input)); + expect(result['properties'], isNot(same(input['properties']))); + }); + + test('Clones a FeatureCollection', () { + final input = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [102.0, 0.5] + }, + "properties": {"prop0": "value0"} + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [102.0, 0.0], + [103.0, 1.0] + ] + }, + "properties": {"prop1": "value1"} + } + ] + }; + + final result = clone(input); + + expect(result, equals(input)); + expect(result['features'][0], isNot(same(input['features'][0]))); + }); + + test('Clones a GeometryCollection', () { + final input = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Point", + "coordinates": [100.0, 0.0] + }, + { + "type": "LineString", + "coordinates": [ + [101.0, 0.0], + [102.0, 1.0] + ] + } + ] + }; + + final result = clone(input); + + expect(result, equals(input)); + expect(result['geometries'][1], isNot(same(input['geometries'][1]))); + }); + + test('Throws error for null input', () { + expect(() => clone(null), throwsArgumentError); + }); + + test('Throws error for unknown GeoJSON type', () { + final input = { + "type": "UnknownThing", + "data": [] + }; + + expect(() => clone(input), throwsArgumentError); + }); + }); +} From 6f2787055dd44aa83a928d3493fee093638c99a6 Mon Sep 17 00:00:00 2001 From: NithyaNN3 Date: Sun, 1 Jun 2025 15:40:48 +1000 Subject: [PATCH 4/6] removed params --- lib/src/dbscan.dart | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/src/dbscan.dart b/lib/src/dbscan.dart index c742e4b0..c78b1dc7 100644 --- a/lib/src/dbscan.dart +++ b/lib/src/dbscan.dart @@ -5,16 +5,10 @@ import 'package:turf/helpers.dart'; import 'package:rbush/rbush.dart'; import 'dart:math' as math; -/// DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is a data clustering algorithm. -/// Given a set of points in some space, it groups together points that are closely packed together -/// (points with many nearby neighbors), marking as outliers points that lie alone in low-density regions. -/// -/// @param {FeatureCollection} points Input points -/// @param {number} maxClusterLength Maximum number of points in a cluster -/// @param {number} minPoints Minimum number of points to form a cluster -/// @param {number} maxRadius Maximum radius between two points to be considered in the same neighborhood -/// @param {boolean} [mutableInput=true] allows GeoJSON input to be mutated (significant performance increase if true) -/// @returns {FeatureCollection} Clustered Points with an additional `cluster` property to each Point +// DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is a data clustering algorithm. +// Given a set of points in some space, it groups together points that are closely packed together +// (points with many nearby neighbors), marking as outliers points that lie alone in low-density regions. + FeatureCollection dbscan( FeatureCollection points, int maxClusterLength, From 495677b191696486133c8a6fb562e84abb8e72a5 Mon Sep 17 00:00:00 2001 From: NithyaNN3 Date: Sun, 1 Jun 2025 19:31:42 +1000 Subject: [PATCH 5/6] fixes --- lib/src/dbscan.dart | 61 +++++++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/lib/src/dbscan.dart b/lib/src/dbscan.dart index c78b1dc7..5764cb19 100644 --- a/lib/src/dbscan.dart +++ b/lib/src/dbscan.dart @@ -1,14 +1,27 @@ -import 'package:geotypes/geotypes.dart'; import 'package:turf/clone.dart'; import 'package:turf/distance.dart'; -import 'package:turf/helpers.dart'; +import 'package:turf/bbox.dart' as turf_bbox; import 'package:rbush/rbush.dart'; -import 'dart:math' as math; // DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is a data clustering algorithm. // Given a set of points in some space, it groups together points that are closely packed together // (points with many nearby neighbors), marking as outliers points that lie alone in low-density regions. +// A wrapper class to make GeoJSON features compatible with RBush spatial indexing. +class SpatialFeature extends RBushElement> { + final Feature feature; + + SpatialFeature(this.feature) + : assert(feature.bbox != null && feature.bbox!.length >= 4, 'Feature must have a bbox'), + super( + minX: feature.bbox![0]!.toDouble(), + minY: feature.bbox![1]!.toDouble(), + maxX: feature.bbox![2]!.toDouble(), + maxY: feature.bbox![3]!.toDouble(), + data: feature, + ); +} + FeatureCollection dbscan( FeatureCollection points, int maxClusterLength, @@ -29,35 +42,45 @@ FeatureCollection dbscan( final noise = List.filled(numberOfPoints, false); int clusterId = 0; + // Ensure all features have a bounding box + for (final feature in clustered.features) { + if (feature.geometry != null && feature.bbox == null) { + feature.bbox = turf_bbox.bbox(feature); + } + } + // Build an R-tree index for efficient neighbor searching - final tree = RBush>(maxEntries: 9); + final tree = RBush>(); for (int i = 0; i < numberOfPoints; i++) { final feature = clustered.features[i]; - if (feature.geometry != null) { - tree.insert(feature.bbox!, feature); + if (feature.geometry != null && feature.bbox != null) { + tree.insert(SpatialFeature(feature)); } - } +} // Function to find neighbors within a given radius List getNeighbors(int pointIndex) { final neighbors = []; final targetPoint = clustered.features[pointIndex]; - if (targetPoint.geometry == null) { + if (targetPoint.geometry == null || targetPoint.bbox == null) { return neighbors; } - final envelope = [ - targetPoint.bbox![0] - maxRadius, - targetPoint.bbox![1] - maxRadius, - targetPoint.bbox![2] + maxRadius, - targetPoint.bbox![3] + maxRadius, - ]; + + final envelope = RBushBox( + minX: targetPoint.bbox![0] - maxRadius, + minY: targetPoint.bbox![1] - maxRadius, + maxX: targetPoint.bbox![2] + maxRadius, + maxY: targetPoint.bbox![3] + maxRadius, + ); final potentialNeighbors = tree.search(envelope); - for (final neighborFeature in potentialNeighbors) { + for (final wrapped in potentialNeighbors) { + final spatialFeature = wrapped as SpatialFeature; + final neighborFeature = spatialFeature.feature; final neighborIndex = clustered.features.indexOf(neighborFeature); if (pointIndex != neighborIndex) { - final distanceInMeters = distance(targetPoint, neighborFeature); - if (distanceInMeters <= maxRadius) { + final dist = distance(targetPoint.geometry!, neighborFeature.geometry!); + if (dist <= maxRadius) { neighbors.add(neighborIndex); } } @@ -102,10 +125,10 @@ FeatureCollection dbscan( } } - // Add the 'cluster' property to noise points (set to null or -1) + // Mark noise points with null cluster for (int i = 0; i < numberOfPoints; i++) { if (noise[i]) { - clustered.features[i].properties['cluster'] = null; // Or -1 + clustered.features[i].properties['cluster'] = null; } } From 6b8c9899d5ba862c2710b51b0386f3764006f872 Mon Sep 17 00:00:00 2001 From: NithyaNN3 Date: Sun, 1 Jun 2025 19:49:37 +1000 Subject: [PATCH 6/6] added dbscan tests --- lib/src/dbscan.dart | 192 +++++++++++++++---------------- test/components/dbscan_test.dart | 67 +++++++++++ 2 files changed, 163 insertions(+), 96 deletions(-) create mode 100644 test/components/dbscan_test.dart diff --git a/lib/src/dbscan.dart b/lib/src/dbscan.dart index 5764cb19..23f6dfe4 100644 --- a/lib/src/dbscan.dart +++ b/lib/src/dbscan.dart @@ -9,128 +9,128 @@ import 'package:rbush/rbush.dart'; // A wrapper class to make GeoJSON features compatible with RBush spatial indexing. class SpatialFeature extends RBushElement> { - final Feature feature; + final Feature feature; - SpatialFeature(this.feature) - : assert(feature.bbox != null && feature.bbox!.length >= 4, 'Feature must have a bbox'), - super( - minX: feature.bbox![0]!.toDouble(), - minY: feature.bbox![1]!.toDouble(), - maxX: feature.bbox![2]!.toDouble(), - maxY: feature.bbox![3]!.toDouble(), - data: feature, - ); + SpatialFeature(this.feature) + : assert(feature.bbox != null && feature.bbox!.length >= 4, 'Feature must have a bbox'), + super( + minX: feature.bbox![0]!.toDouble(), + minY: feature.bbox![1]!.toDouble(), + maxX: feature.bbox![2]!.toDouble(), + maxY: feature.bbox![3]!.toDouble(), + data: feature, + ); } FeatureCollection dbscan( - FeatureCollection points, - int maxClusterLength, - int minPoints, - double maxRadius, { - bool mutableInput = true, -}) { - if (minPoints <= 0) { - throw ArgumentError('minPoints must be greater than 0'); - } - if (maxRadius < 0) { - throw ArgumentError('maxRadius must be greater than or equal to 0'); - } + FeatureCollection points, + int maxClusterLength, + int minPoints, + double maxRadius, { + bool mutableInput = true, + }) { + if (minPoints <= 0) { + throw ArgumentError('minPoints must be greater than 0'); + } + if (maxRadius < 0) { + throw ArgumentError('maxRadius must be greater than or equal to 0'); + } - final numberOfPoints = points.features.length; - final clustered = mutableInput ? points : clone(points); - final visited = List.filled(numberOfPoints, false); - final noise = List.filled(numberOfPoints, false); - int clusterId = 0; + final numberOfPoints = points.features.length; + final clustered = mutableInput ? points : clone(points); + final visited = List.filled(numberOfPoints, false); + final noise = List.filled(numberOfPoints, false); + int clusterId = 0; - // Ensure all features have a bounding box - for (final feature in clustered.features) { - if (feature.geometry != null && feature.bbox == null) { - feature.bbox = turf_bbox.bbox(feature); + // Ensure all features have a bounding box + for (final feature in clustered.features) { + if (feature.geometry != null && feature.bbox == null) { + feature.bbox = turf_bbox.bbox(feature); + } } - } - // Build an R-tree index for efficient neighbor searching - final tree = RBush>(); - for (int i = 0; i < numberOfPoints; i++) { - final feature = clustered.features[i]; - if (feature.geometry != null && feature.bbox != null) { - tree.insert(SpatialFeature(feature)); + // Build an R-tree index for efficient neighbor searching + final tree = RBush>(); + for (int i = 0; i < numberOfPoints; i++) { + final feature = clustered.features[i]; + if (feature.geometry != null && feature.bbox != null) { + tree.insert(SpatialFeature(feature)); + } } -} - // Function to find neighbors within a given radius - List getNeighbors(int pointIndex) { - final neighbors = []; - final targetPoint = clustered.features[pointIndex]; - if (targetPoint.geometry == null || targetPoint.bbox == null) { - return neighbors; - } + // Function to find neighbors within a given radius + List getNeighbors(int pointIndex) { + final neighbors = []; + final targetPoint = clustered.features[pointIndex]; + if (targetPoint.geometry == null || targetPoint.bbox == null) { + return neighbors; + } - final envelope = RBushBox( - minX: targetPoint.bbox![0] - maxRadius, - minY: targetPoint.bbox![1] - maxRadius, - maxX: targetPoint.bbox![2] + maxRadius, - maxY: targetPoint.bbox![3] + maxRadius, - ); + final envelope = RBushBox( + minX: targetPoint.bbox![0] - maxRadius, + minY: targetPoint.bbox![1] - maxRadius, + maxX: targetPoint.bbox![2] + maxRadius, + maxY: targetPoint.bbox![3] + maxRadius, + ); - final potentialNeighbors = tree.search(envelope); - for (final wrapped in potentialNeighbors) { - final spatialFeature = wrapped as SpatialFeature; - final neighborFeature = spatialFeature.feature; - final neighborIndex = clustered.features.indexOf(neighborFeature); - if (pointIndex != neighborIndex) { - final dist = distance(targetPoint.geometry!, neighborFeature.geometry!); - if (dist <= maxRadius) { - neighbors.add(neighborIndex); + final potentialNeighbors = tree.search(envelope); + for (final wrapped in potentialNeighbors) { + final spatialFeature = wrapped as SpatialFeature; + final neighborFeature = spatialFeature.feature; + final neighborIndex = clustered.features.indexOf(neighborFeature); + if (pointIndex != neighborIndex) { + final dist = distance(targetPoint.geometry!, neighborFeature.geometry!); + if (dist <= maxRadius) { + neighbors.add(neighborIndex); + } + } } + return neighbors; } - } - return neighbors; - } // Expand the cluster recursively void expandCluster(int pointIndex, List neighbors) { - visited[pointIndex] = true; - clustered.features[pointIndex].properties['cluster'] = clusterId; + visited[pointIndex] = true; + clustered.features[pointIndex].properties['cluster'] = clusterId; - int i = 0; - while (i < neighbors.length) { - final neighborIndex = neighbors[i]; - if (!visited[neighborIndex]) { - visited[neighborIndex] = true; - clustered.features[neighborIndex].properties['cluster'] = clusterId; - final newNeighbors = getNeighbors(neighborIndex); - if (newNeighbors.length >= minPoints) { - neighbors.addAll(newNeighbors.where((n) => !neighbors.contains(n))); + int i = 0; + while (i < neighbors.length) { + final neighborIndex = neighbors[i]; + if (!visited[neighborIndex]) { + visited[neighborIndex] = true; + clustered.features[neighborIndex].properties['cluster'] = clusterId; + final newNeighbors = getNeighbors(neighborIndex); + if (newNeighbors.length >= minPoints) { + neighbors.addAll(newNeighbors.where((n) => !neighbors.contains(n))); + } + } + i++; } } - i++; - } - } // Iterate through each point for (int i = 0; i < numberOfPoints; i++) { - if (!visited[i]) { - final neighbors = getNeighbors(i); - if (neighbors.length < minPoints) { - noise[i] = true; - } else { - expandCluster(i, neighbors); - clusterId++; - if (clusterId > maxClusterLength) { - throw ArgumentError( - 'Cluster exceeded maxClusterLength ($maxClusterLength)'); - } + if (!visited[i]) { + final neighbors = getNeighbors(i); + if (neighbors.length < minPoints) { + noise[i] = true; + } else { + expandCluster(i, neighbors); + clusterId++; + if (clusterId > maxClusterLength) { + throw ArgumentError( + 'Cluster exceeded maxClusterLength ($maxClusterLength)'); + } + } } - } } - // Mark noise points with null cluster - for (int i = 0; i < numberOfPoints; i++) { - if (noise[i]) { - clustered.features[i].properties['cluster'] = null; + // Mark noise points with null cluster + for (int i = 0; i < numberOfPoints; i++) { + if (noise[i]) { + clustered.features[i].properties['cluster'] = null; + } } - } - return clustered; + return clustered; } \ No newline at end of file diff --git a/test/components/dbscan_test.dart b/test/components/dbscan_test.dart new file mode 100644 index 00000000..991a2451 --- /dev/null +++ b/test/components/dbscan_test.dart @@ -0,0 +1,67 @@ +import 'package:test/test.dart'; +import 'package:turf/turf.dart'; +import 'package:turf/dbscan.dart'; + +void main() { + group('DBSCAN clustering', () { + test('clusters points correctly', () { + // Create some sample points that form two clusters + final points = FeatureCollection(features: [ + Feature(geometry: Point(coordinates: Position(0, 0))), + Feature(geometry: Point(coordinates: Position(0.1, 0.1))), + Feature(geometry: Point(coordinates: Position(0.2, 0.2))), + Feature(geometry: Point(coordinates: Position(5, 5))), + Feature(geometry: Point(coordinates: Position(5.1, 5.1))), + ]); + + // Run dbscan with parameters: + // maxClusterLength = 10 clusters max + // minPoints = 2 points needed to form a cluster + // maxRadius = 20000 meters (~20 km) + final clustered = dbscan(points, 10, 2, 20000); + + // Check clusters assigned: + final clusters = clustered.features.map((f) => f.properties['cluster']).toList(); + + // Points 0,1,2 should have same cluster id (0) + expect(clusters[0], equals(0)); + expect(clusters[1], equals(0)); + expect(clusters[2], equals(0)); + + // Points 3,4 should have same cluster id (1) + expect(clusters[3], equals(1)); + expect(clusters[4], equals(1)); + }); + + test('noise points are marked with null cluster', () { + // Points spaced far apart, no clusters + final points = FeatureCollection(features: [ + Feature(geometry: Point(coordinates: Position(0, 0))), + Feature(geometry: Point(coordinates: Position(10, 10))), + ]); + + final clustered = dbscan(points, 10, 2, 1000); // maxRadius only 1 km + + // Both points should be noise (cluster == null) + for (final feature in clustered.features) { + expect(feature.properties['cluster'], isNull); + } + }); + + test('throws error if minPoints <= 0', () { + final points = FeatureCollection(features: [ + Feature(geometry: Point(coordinates: Position(0, 0))), + ]); + + expect(() => dbscan(points, 10, 0, 1000), throwsArgumentError); + }); + + test('throws error if maxRadius < 0', () { + final points = FeatureCollection(features: [ + Feature(geometry: Point(coordinates: Position(0, 0))), + ]); + + expect(() => dbscan(points, 10, 1, -10), throwsArgumentError); + }); + }); +} \ No newline at end of file