From df0dacdff5662d0626f7c5ae25615096e6b6fe9c Mon Sep 17 00:00:00 2001 From: Jack Newman Date: Tue, 13 May 2025 13:43:01 +1000 Subject: [PATCH] Add combine function to convert feature collections to multi-geometries --- lib/combine.dart | 4 + lib/src/combine.dart | 111 +++++++++++++ lib/turf.dart | 1 + test/components/combine_test.dart | 258 ++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+) create mode 100644 lib/combine.dart create mode 100644 lib/src/combine.dart create mode 100644 test/components/combine_test.dart diff --git a/lib/combine.dart b/lib/combine.dart new file mode 100644 index 00000000..0fd92b97 --- /dev/null +++ b/lib/combine.dart @@ -0,0 +1,4 @@ +library turf_combine; + +export 'package:geotypes/geotypes.dart'; +export 'src/combine.dart'; diff --git a/lib/src/combine.dart b/lib/src/combine.dart new file mode 100644 index 00000000..aad121fa --- /dev/null +++ b/lib/src/combine.dart @@ -0,0 +1,111 @@ +import 'package:turf/meta.dart'; + +/// Combines a [FeatureCollection] of Point, LineString or Polygon features +/// into a single MultiPoint, MultiLineString or MultiPolygon feature. +/// +/// The [collection] must be a FeatureCollection of the same geometry type. +/// Supported types are Point, LineString, and Polygon. +/// +/// Returns a [Feature] with a Multi* geometry containing all coordinates from the input collection. +/// Throws [ArgumentError] if features have inconsistent geometry types or unsupported types. +/// +/// If [mergeProperties] is true, properties from the first feature will be preserved. +/// Otherwise, properties will be empty by default. +/// +/// See: https://turfjs.org/docs/#combine +Feature combine( + FeatureCollection collection, { + bool mergeProperties = false, +}) { + // Validate that the collection is not empty + if (collection.features.isEmpty) { + throw ArgumentError('FeatureCollection must contain at least one feature'); + } + + // Get the geometry type of the first feature to validate consistency + final firstFeature = collection.features.first; + final geometryType = firstFeature.geometry?.runtimeType; + if (geometryType == null) { + throw ArgumentError('Feature must have a geometry'); + } + + final firstGeometry = firstFeature.geometry!; + + // Ensure all features have the same geometry type + for (final feature in collection.features) { + final geometry = feature.geometry; + if (geometry == null) { + throw ArgumentError('All features must have a geometry'); + } + + if (geometry.runtimeType != firstGeometry.runtimeType) { + throw ArgumentError( + 'All features must have the same geometry type. ' + 'Found: ${geometry.type}, expected: ${firstGeometry.type}', + ); + } + } + + // Set of properties to include in result if mergeProperties is true + final properties = mergeProperties && firstFeature.properties != null + ? Map.from(firstFeature.properties!) + : {}; + + // Create the appropriate geometry based on type + GeometryObject resultGeometry; + + if (firstGeometry is Point) { + // Combine all Point coordinates into a single MultiPoint + final coordinates = []; + for (final feature in collection.features) { + final point = feature.geometry as Point; + coordinates.add(point.coordinates); + } + + resultGeometry = MultiPoint(coordinates: coordinates); + } else if (firstGeometry is LineString) { + // Combine all LineString coordinate arrays into a MultiLineString + final coordinates = >[]; + for (final feature in collection.features) { + final line = feature.geometry as LineString; + coordinates.add(line.coordinates); + } + + resultGeometry = MultiLineString(coordinates: coordinates); + } else if (firstGeometry is Polygon) { + // Combine all Polygon coordinate arrays into a MultiPolygon + final coordinates = >>[]; + for (final feature in collection.features) { + final polygon = feature.geometry as Polygon; + coordinates.add(polygon.coordinates); + } + + resultGeometry = MultiPolygon(coordinates: coordinates); + } else { + // Throw if unsupported geometry type is encountered + throw ArgumentError( + 'Unsupported geometry type: ${firstGeometry.type}. ' + 'Only Point, LineString, and Polygon are supported.', + ); + } + + // Create the Feature result + final result = Feature( + geometry: resultGeometry, + properties: properties, + ); + + // Apply otherMembers from the first feature to preserve GeoJSON compliance + final resultJson = result.toJson(); + final firstFeatureJson = firstFeature.toJson(); + + // Copy any non-standard GeoJSON fields (otherMembers) + firstFeatureJson.forEach((key, value) { + if (key != 'type' && key != 'geometry' && key != 'properties' && key != 'id') { + resultJson[key] = value; + } + }); + + // Return the result with otherMembers preserved + return Feature.fromJson(resultJson); +} diff --git a/lib/turf.dart b/lib/turf.dart index 482694bb..03d091d9 100644 --- a/lib/turf.dart +++ b/lib/turf.dart @@ -9,6 +9,7 @@ export 'bearing.dart'; export 'boolean.dart'; export 'center.dart'; export 'centroid.dart'; +export 'combine.dart'; export 'clean_coords.dart'; export 'clusters.dart'; export 'destination.dart'; diff --git a/test/components/combine_test.dart b/test/components/combine_test.dart new file mode 100644 index 00000000..f9dcd1ed --- /dev/null +++ b/test/components/combine_test.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; + +import 'package:geotypes/geotypes.dart'; +import 'package:test/test.dart'; +import 'package:turf/src/combine.dart'; + +void main() { + group('combine:', () { + // Geometry-based tests + group('geometry transformations:', () { + test('combines multiple points to a MultiPoint', () { + final point1 = Feature( + geometry: Point(coordinates: Position.of([0, 0])), + properties: {'name': 'point1'}, + ); + final point2 = Feature( + geometry: Point(coordinates: Position.of([1, 1])), + properties: {'name': 'point2'}, + ); + final point3 = Feature( + geometry: Point(coordinates: Position.of([2, 2, 10])), // With altitude + properties: {'name': 'point3'}, + ); + + final collection = FeatureCollection(features: [point1, point2, point3]); + final result = combine(collection); + + expect(result.geometry, isA()); + expect((result.geometry as MultiPoint).coordinates.length, 3); + // Check altitude preservation + expect((result.geometry as MultiPoint).coordinates[2].length, 3); + expect((result.geometry as MultiPoint).coordinates[2][2], 10); + }); + + test('combines multiple linestrings to a MultiLineString', () { + final line1 = Feature( + geometry: LineString(coordinates: [ + Position.of([0, 0]), + Position.of([1, 1]), + ]), + properties: {'name': 'line1'}, + ); + final line2 = Feature( + geometry: LineString(coordinates: [ + Position.of([2, 2]), + Position.of([3, 3]), + ]), + properties: {'name': 'line2'}, + ); + final line3 = Feature( + geometry: LineString(coordinates: [ + Position.of([4, 4, 10]), // With altitude + Position.of([5, 5, 15]), // With altitude + ]), + properties: {'name': 'line3'}, + ); + + final collection = FeatureCollection(features: [line1, line2, line3]); + final result = combine(collection); + + expect(result.geometry, isA()); + expect((result.geometry as MultiLineString).coordinates.length, 3); + // Check altitude preservation + expect((result.geometry as MultiLineString).coordinates[2][0].length, 3); + expect((result.geometry as MultiLineString).coordinates[2][0][2], 10); + expect((result.geometry as MultiLineString).coordinates[2][1][2], 15); + }); + + test('combines multiple polygons to a MultiPolygon', () { + final poly1 = Feature( + geometry: Polygon(coordinates: [ + [ + Position.of([0, 0]), + Position.of([1, 0]), + Position.of([1, 1]), + Position.of([0, 1]), + Position.of([0, 0]), + ] + ]), + properties: {'name': 'poly1'}, + ); + final poly2 = Feature( + geometry: Polygon(coordinates: [ + [ + Position.of([2, 2]), + Position.of([3, 2]), + Position.of([3, 3]), + Position.of([2, 3]), + Position.of([2, 2]), + ] + ]), + properties: {'name': 'poly2'}, + ); + final poly3 = Feature( + geometry: Polygon(coordinates: [ + [ + Position.of([4, 4, 10]), // With altitude + Position.of([5, 4, 10]), + Position.of([5, 5, 10]), + Position.of([4, 5, 10]), + Position.of([4, 4, 10]), + ] + ]), + properties: {'name': 'poly3'}, + ); + + final collection = FeatureCollection(features: [poly1, poly2, poly3]); + final result = combine(collection); + + expect(result.geometry, isA()); + expect((result.geometry as MultiPolygon).coordinates.length, 3); + // Check altitude preservation + expect((result.geometry as MultiPolygon).coordinates[2][0][0].length, 3); + expect((result.geometry as MultiPolygon).coordinates[2][0][0][2], 10); + }); + + test('preserves negative or high-altitude z-values', () { + // Test for extreme altitude values (negative and high) + final point1 = Feature( + geometry: Point(coordinates: Position.of([0, 0, -9999.5])), // Deep negative altitude + properties: {'name': 'deep_point'}, + ); + final point2 = Feature( + geometry: Point(coordinates: Position.of([1, 1, 9999.5])), // High positive altitude + properties: {'name': 'high_point'}, + ); + + final collection = FeatureCollection(features: [point1, point2]); + final result = combine(collection); + + expect(result.geometry, isA()); + expect((result.geometry as MultiPoint).coordinates.length, 2); + + // Check extreme altitude preservation + expect((result.geometry as MultiPoint).coordinates[0].length, 3); + expect((result.geometry as MultiPoint).coordinates[0][2], -9999.5); + expect((result.geometry as MultiPoint).coordinates[1].length, 3); + expect((result.geometry as MultiPoint).coordinates[1][2], 9999.5); + }); + }); + + // Error tests + group('validation and errors:', () { + test('throws error on mixed geometry types', () { + final point = Feature( + geometry: Point(coordinates: Position.of([0, 0])), + properties: {'name': 'point'}, + ); + final line = Feature( + geometry: LineString(coordinates: [ + Position.of([0, 0]), + Position.of([1, 1]), + ]), + properties: {'name': 'line'}, + ); + + final collection = FeatureCollection(features: [point, line]); + expect(() => combine(collection), throwsA(isA())); + }); + + test('throws error on empty collection', () { + final collection = FeatureCollection(features: []); + expect(() => combine(collection), throwsA(isA())); + }); + + test('throws error on unsupported geometry types (validation test)', () { + // This is a validation test - GeometryCollection is not claimed to be + // supported by combine(), which only works with Point, LineString, and Polygon. + final geomCollection = Feature( + geometry: GeometryCollection(geometries: [ + Point(coordinates: Position.of([0, 0])), + LineString(coordinates: [ + Position.of([0, 0]), + Position.of([1, 1]), + ]), + ]), + properties: {'name': 'geomCollection'}, + ); + + final collection = FeatureCollection(features: [geomCollection, geomCollection]); + expect(() => combine(collection), throwsA(isA())); + }); + }); + + // Property handling tests + group('property handling:', () { + test('has empty properties by default', () { + final point1 = Feature( + geometry: Point(coordinates: Position.of([0, 0])), + properties: {'name': 'point1', 'value': 42}, + ); + final point2 = Feature( + geometry: Point(coordinates: Position.of([1, 1])), + properties: {'name': 'point2', 'otherValue': 'test'}, + ); + + final collection = FeatureCollection(features: [point1, point2]); + final result = combine(collection); + + // By default, properties should be empty + expect(result.properties, isEmpty); + }); + + test('preserves properties from first feature when mergeProperties=true', () { + final point1 = Feature( + geometry: Point(coordinates: Position.of([0, 0])), + properties: {'name': 'point1', 'value': 42}, + ); + final point2 = Feature( + geometry: Point(coordinates: Position.of([1, 1])), + properties: {'name': 'point2', 'otherValue': 'test'}, + ); + + final collection = FeatureCollection(features: [point1, point2]); + final result = combine(collection, mergeProperties: true); + + // When mergeProperties is true, copies properties from first feature only + expect(result.properties!['name'], 'point1'); + expect(result.properties!['value'], 42); + expect(result.properties!.containsKey('otherValue'), isFalse); + }); + }); + + // GeoJSON otherMembers tests + group('GeoJSON compliance:', () { + test('preserves otherMembers in output', () { + // Create a source feature with otherMembers by parsing from JSON + final jsonStr = '''{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0, 0] + }, + "properties": {"name": "point1"}, + "customField": "custom value", + "metaData": {"source": "test"} + }'''; + + final sourceFeature = Feature.fromJson(jsonDecode(jsonStr)); + + // Create a feature collection with this feature + final collection = FeatureCollection(features: [sourceFeature]); + + // Combine (which should use the same feature as the source for the result) + final result = combine(collection, mergeProperties: true); + + // Convert to JSON and check for preservation of otherMembers + final resultJson = result.toJson(); + + // Verify the otherMembers exist in the result + expect(resultJson.containsKey('customField'), isTrue); + expect(resultJson['customField'], 'custom value'); + expect(resultJson.containsKey('metaData'), isTrue); + expect(resultJson['metaData']?['source'], 'test'); + }); + }); + }); +}