Skip to content

Commit dae97ec

Browse files
authored
Merge pull request #5 from deanpapas/Combine
Add combine function to convert feature collections to multi-geometries
2 parents 8c7e304 + df0dacd commit dae97ec

File tree

4 files changed

+374
-0
lines changed

4 files changed

+374
-0
lines changed

lib/combine.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
library turf_combine;
2+
3+
export 'package:geotypes/geotypes.dart';
4+
export 'src/combine.dart';

lib/src/combine.dart

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import 'package:turf/meta.dart';
2+
3+
/// Combines a [FeatureCollection] of Point, LineString or Polygon features
4+
/// into a single MultiPoint, MultiLineString or MultiPolygon feature.
5+
///
6+
/// The [collection] must be a FeatureCollection of the same geometry type.
7+
/// Supported types are Point, LineString, and Polygon.
8+
///
9+
/// Returns a [Feature] with a Multi* geometry containing all coordinates from the input collection.
10+
/// Throws [ArgumentError] if features have inconsistent geometry types or unsupported types.
11+
///
12+
/// If [mergeProperties] is true, properties from the first feature will be preserved.
13+
/// Otherwise, properties will be empty by default.
14+
///
15+
/// See: https://turfjs.org/docs/#combine
16+
Feature combine(
17+
FeatureCollection collection, {
18+
bool mergeProperties = false,
19+
}) {
20+
// Validate that the collection is not empty
21+
if (collection.features.isEmpty) {
22+
throw ArgumentError('FeatureCollection must contain at least one feature');
23+
}
24+
25+
// Get the geometry type of the first feature to validate consistency
26+
final firstFeature = collection.features.first;
27+
final geometryType = firstFeature.geometry?.runtimeType;
28+
if (geometryType == null) {
29+
throw ArgumentError('Feature must have a geometry');
30+
}
31+
32+
final firstGeometry = firstFeature.geometry!;
33+
34+
// Ensure all features have the same geometry type
35+
for (final feature in collection.features) {
36+
final geometry = feature.geometry;
37+
if (geometry == null) {
38+
throw ArgumentError('All features must have a geometry');
39+
}
40+
41+
if (geometry.runtimeType != firstGeometry.runtimeType) {
42+
throw ArgumentError(
43+
'All features must have the same geometry type. '
44+
'Found: ${geometry.type}, expected: ${firstGeometry.type}',
45+
);
46+
}
47+
}
48+
49+
// Set of properties to include in result if mergeProperties is true
50+
final properties = mergeProperties && firstFeature.properties != null
51+
? Map<String, dynamic>.from(firstFeature.properties!)
52+
: <String, dynamic>{};
53+
54+
// Create the appropriate geometry based on type
55+
GeometryObject resultGeometry;
56+
57+
if (firstGeometry is Point) {
58+
// Combine all Point coordinates into a single MultiPoint
59+
final coordinates = <Position>[];
60+
for (final feature in collection.features) {
61+
final point = feature.geometry as Point;
62+
coordinates.add(point.coordinates);
63+
}
64+
65+
resultGeometry = MultiPoint(coordinates: coordinates);
66+
} else if (firstGeometry is LineString) {
67+
// Combine all LineString coordinate arrays into a MultiLineString
68+
final coordinates = <List<Position>>[];
69+
for (final feature in collection.features) {
70+
final line = feature.geometry as LineString;
71+
coordinates.add(line.coordinates);
72+
}
73+
74+
resultGeometry = MultiLineString(coordinates: coordinates);
75+
} else if (firstGeometry is Polygon) {
76+
// Combine all Polygon coordinate arrays into a MultiPolygon
77+
final coordinates = <List<List<Position>>>[];
78+
for (final feature in collection.features) {
79+
final polygon = feature.geometry as Polygon;
80+
coordinates.add(polygon.coordinates);
81+
}
82+
83+
resultGeometry = MultiPolygon(coordinates: coordinates);
84+
} else {
85+
// Throw if unsupported geometry type is encountered
86+
throw ArgumentError(
87+
'Unsupported geometry type: ${firstGeometry.type}. '
88+
'Only Point, LineString, and Polygon are supported.',
89+
);
90+
}
91+
92+
// Create the Feature result
93+
final result = Feature(
94+
geometry: resultGeometry,
95+
properties: properties,
96+
);
97+
98+
// Apply otherMembers from the first feature to preserve GeoJSON compliance
99+
final resultJson = result.toJson();
100+
final firstFeatureJson = firstFeature.toJson();
101+
102+
// Copy any non-standard GeoJSON fields (otherMembers)
103+
firstFeatureJson.forEach((key, value) {
104+
if (key != 'type' && key != 'geometry' && key != 'properties' && key != 'id') {
105+
resultJson[key] = value;
106+
}
107+
});
108+
109+
// Return the result with otherMembers preserved
110+
return Feature.fromJson(resultJson);
111+
}

lib/turf.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export 'bearing.dart';
99
export 'boolean.dart';
1010
export 'center.dart';
1111
export 'centroid.dart';
12+
export 'combine.dart';
1213
export 'clean_coords.dart';
1314
export 'clusters.dart';
1415
export 'destination.dart';

test/components/combine_test.dart

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import 'dart:convert';
2+
3+
import 'package:geotypes/geotypes.dart';
4+
import 'package:test/test.dart';
5+
import 'package:turf/src/combine.dart';
6+
7+
void main() {
8+
group('combine:', () {
9+
// Geometry-based tests
10+
group('geometry transformations:', () {
11+
test('combines multiple points to a MultiPoint', () {
12+
final point1 = Feature(
13+
geometry: Point(coordinates: Position.of([0, 0])),
14+
properties: {'name': 'point1'},
15+
);
16+
final point2 = Feature(
17+
geometry: Point(coordinates: Position.of([1, 1])),
18+
properties: {'name': 'point2'},
19+
);
20+
final point3 = Feature(
21+
geometry: Point(coordinates: Position.of([2, 2, 10])), // With altitude
22+
properties: {'name': 'point3'},
23+
);
24+
25+
final collection = FeatureCollection(features: [point1, point2, point3]);
26+
final result = combine(collection);
27+
28+
expect(result.geometry, isA<MultiPoint>());
29+
expect((result.geometry as MultiPoint).coordinates.length, 3);
30+
// Check altitude preservation
31+
expect((result.geometry as MultiPoint).coordinates[2].length, 3);
32+
expect((result.geometry as MultiPoint).coordinates[2][2], 10);
33+
});
34+
35+
test('combines multiple linestrings to a MultiLineString', () {
36+
final line1 = Feature(
37+
geometry: LineString(coordinates: [
38+
Position.of([0, 0]),
39+
Position.of([1, 1]),
40+
]),
41+
properties: {'name': 'line1'},
42+
);
43+
final line2 = Feature(
44+
geometry: LineString(coordinates: [
45+
Position.of([2, 2]),
46+
Position.of([3, 3]),
47+
]),
48+
properties: {'name': 'line2'},
49+
);
50+
final line3 = Feature(
51+
geometry: LineString(coordinates: [
52+
Position.of([4, 4, 10]), // With altitude
53+
Position.of([5, 5, 15]), // With altitude
54+
]),
55+
properties: {'name': 'line3'},
56+
);
57+
58+
final collection = FeatureCollection(features: [line1, line2, line3]);
59+
final result = combine(collection);
60+
61+
expect(result.geometry, isA<MultiLineString>());
62+
expect((result.geometry as MultiLineString).coordinates.length, 3);
63+
// Check altitude preservation
64+
expect((result.geometry as MultiLineString).coordinates[2][0].length, 3);
65+
expect((result.geometry as MultiLineString).coordinates[2][0][2], 10);
66+
expect((result.geometry as MultiLineString).coordinates[2][1][2], 15);
67+
});
68+
69+
test('combines multiple polygons to a MultiPolygon', () {
70+
final poly1 = Feature(
71+
geometry: Polygon(coordinates: [
72+
[
73+
Position.of([0, 0]),
74+
Position.of([1, 0]),
75+
Position.of([1, 1]),
76+
Position.of([0, 1]),
77+
Position.of([0, 0]),
78+
]
79+
]),
80+
properties: {'name': 'poly1'},
81+
);
82+
final poly2 = Feature(
83+
geometry: Polygon(coordinates: [
84+
[
85+
Position.of([2, 2]),
86+
Position.of([3, 2]),
87+
Position.of([3, 3]),
88+
Position.of([2, 3]),
89+
Position.of([2, 2]),
90+
]
91+
]),
92+
properties: {'name': 'poly2'},
93+
);
94+
final poly3 = Feature(
95+
geometry: Polygon(coordinates: [
96+
[
97+
Position.of([4, 4, 10]), // With altitude
98+
Position.of([5, 4, 10]),
99+
Position.of([5, 5, 10]),
100+
Position.of([4, 5, 10]),
101+
Position.of([4, 4, 10]),
102+
]
103+
]),
104+
properties: {'name': 'poly3'},
105+
);
106+
107+
final collection = FeatureCollection(features: [poly1, poly2, poly3]);
108+
final result = combine(collection);
109+
110+
expect(result.geometry, isA<MultiPolygon>());
111+
expect((result.geometry as MultiPolygon).coordinates.length, 3);
112+
// Check altitude preservation
113+
expect((result.geometry as MultiPolygon).coordinates[2][0][0].length, 3);
114+
expect((result.geometry as MultiPolygon).coordinates[2][0][0][2], 10);
115+
});
116+
117+
test('preserves negative or high-altitude z-values', () {
118+
// Test for extreme altitude values (negative and high)
119+
final point1 = Feature(
120+
geometry: Point(coordinates: Position.of([0, 0, -9999.5])), // Deep negative altitude
121+
properties: {'name': 'deep_point'},
122+
);
123+
final point2 = Feature(
124+
geometry: Point(coordinates: Position.of([1, 1, 9999.5])), // High positive altitude
125+
properties: {'name': 'high_point'},
126+
);
127+
128+
final collection = FeatureCollection(features: [point1, point2]);
129+
final result = combine(collection);
130+
131+
expect(result.geometry, isA<MultiPoint>());
132+
expect((result.geometry as MultiPoint).coordinates.length, 2);
133+
134+
// Check extreme altitude preservation
135+
expect((result.geometry as MultiPoint).coordinates[0].length, 3);
136+
expect((result.geometry as MultiPoint).coordinates[0][2], -9999.5);
137+
expect((result.geometry as MultiPoint).coordinates[1].length, 3);
138+
expect((result.geometry as MultiPoint).coordinates[1][2], 9999.5);
139+
});
140+
});
141+
142+
// Error tests
143+
group('validation and errors:', () {
144+
test('throws error on mixed geometry types', () {
145+
final point = Feature(
146+
geometry: Point(coordinates: Position.of([0, 0])),
147+
properties: {'name': 'point'},
148+
);
149+
final line = Feature(
150+
geometry: LineString(coordinates: [
151+
Position.of([0, 0]),
152+
Position.of([1, 1]),
153+
]),
154+
properties: {'name': 'line'},
155+
);
156+
157+
final collection = FeatureCollection(features: [point, line]);
158+
expect(() => combine(collection), throwsA(isA<ArgumentError>()));
159+
});
160+
161+
test('throws error on empty collection', () {
162+
final collection = FeatureCollection<Point>(features: []);
163+
expect(() => combine(collection), throwsA(isA<ArgumentError>()));
164+
});
165+
166+
test('throws error on unsupported geometry types (validation test)', () {
167+
// This is a validation test - GeometryCollection is not claimed to be
168+
// supported by combine(), which only works with Point, LineString, and Polygon.
169+
final geomCollection = Feature(
170+
geometry: GeometryCollection(geometries: [
171+
Point(coordinates: Position.of([0, 0])),
172+
LineString(coordinates: [
173+
Position.of([0, 0]),
174+
Position.of([1, 1]),
175+
]),
176+
]),
177+
properties: {'name': 'geomCollection'},
178+
);
179+
180+
final collection = FeatureCollection(features: [geomCollection, geomCollection]);
181+
expect(() => combine(collection), throwsA(isA<ArgumentError>()));
182+
});
183+
});
184+
185+
// Property handling tests
186+
group('property handling:', () {
187+
test('has empty properties by default', () {
188+
final point1 = Feature(
189+
geometry: Point(coordinates: Position.of([0, 0])),
190+
properties: {'name': 'point1', 'value': 42},
191+
);
192+
final point2 = Feature(
193+
geometry: Point(coordinates: Position.of([1, 1])),
194+
properties: {'name': 'point2', 'otherValue': 'test'},
195+
);
196+
197+
final collection = FeatureCollection(features: [point1, point2]);
198+
final result = combine(collection);
199+
200+
// By default, properties should be empty
201+
expect(result.properties, isEmpty);
202+
});
203+
204+
test('preserves properties from first feature when mergeProperties=true', () {
205+
final point1 = Feature(
206+
geometry: Point(coordinates: Position.of([0, 0])),
207+
properties: {'name': 'point1', 'value': 42},
208+
);
209+
final point2 = Feature(
210+
geometry: Point(coordinates: Position.of([1, 1])),
211+
properties: {'name': 'point2', 'otherValue': 'test'},
212+
);
213+
214+
final collection = FeatureCollection(features: [point1, point2]);
215+
final result = combine(collection, mergeProperties: true);
216+
217+
// When mergeProperties is true, copies properties from first feature only
218+
expect(result.properties!['name'], 'point1');
219+
expect(result.properties!['value'], 42);
220+
expect(result.properties!.containsKey('otherValue'), isFalse);
221+
});
222+
});
223+
224+
// GeoJSON otherMembers tests
225+
group('GeoJSON compliance:', () {
226+
test('preserves otherMembers in output', () {
227+
// Create a source feature with otherMembers by parsing from JSON
228+
final jsonStr = '''{
229+
"type": "Feature",
230+
"geometry": {
231+
"type": "Point",
232+
"coordinates": [0, 0]
233+
},
234+
"properties": {"name": "point1"},
235+
"customField": "custom value",
236+
"metaData": {"source": "test"}
237+
}''';
238+
239+
final sourceFeature = Feature<Point>.fromJson(jsonDecode(jsonStr));
240+
241+
// Create a feature collection with this feature
242+
final collection = FeatureCollection(features: [sourceFeature]);
243+
244+
// Combine (which should use the same feature as the source for the result)
245+
final result = combine(collection, mergeProperties: true);
246+
247+
// Convert to JSON and check for preservation of otherMembers
248+
final resultJson = result.toJson();
249+
250+
// Verify the otherMembers exist in the result
251+
expect(resultJson.containsKey('customField'), isTrue);
252+
expect(resultJson['customField'], 'custom value');
253+
expect(resultJson.containsKey('metaData'), isTrue);
254+
expect(resultJson['metaData']?['source'], 'test');
255+
});
256+
});
257+
});
258+
}

0 commit comments

Comments
 (0)