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..90a41617 --- /dev/null +++ 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 new file mode 100644 index 00000000..23f6dfe4 --- /dev/null +++ b/lib/src/dbscan.dart @@ -0,0 +1,136 @@ +import 'package:turf/clone.dart'; +import 'package:turf/distance.dart'; +import 'package:turf/bbox.dart' as turf_bbox; +import 'package:rbush/rbush.dart'; + +// 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, + 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; + + // 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)); + } + } + + // 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 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; + } + + // 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; + clustered.features[neighborIndex].properties['cluster'] = clusterId; + final newNeighbors = getNeighbors(neighborIndex); + if (newNeighbors.length >= minPoints) { + neighbors.addAll(newNeighbors.where((n) => !neighbors.contains(n))); + } + } + 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)'); + } + } + } + } + + // Mark noise points with null cluster + for (int i = 0; i < numberOfPoints; i++) { + if (noise[i]) { + clustered.features[i].properties['cluster'] = null; + } + } + + return clustered; +} \ No newline at end of file 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'; 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); + }); + }); +} 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