diff --git a/lib/src/turf_equality_base.dart b/lib/src/turf_equality_base.dart index b614bbe..fb6535a 100644 --- a/lib/src/turf_equality_base.dart +++ b/lib/src/turf_equality_base.dart @@ -1,11 +1,6 @@ import 'package:turf/turf.dart'; import 'package:turf/helpers.dart' as rounder; -typedef EqualityObjectComparator = bool Function( - GeoJSONObject obj1, - GeoJSONObject obj2, -); - class Equality { /// Decides the number of fraction digits in a [double] final int precision; @@ -16,114 +11,98 @@ class Equality { /// If true, consider two [Polygon]s with shifted [Position]s as the same. final bool shiftedPolygons; - // final EqualityObjectComparator objectComparator; + + final int Function(GeoJSONObject obj1, GeoJSONObject obj2)? objectComparator; Equality({ + this.objectComparator, this.precision = 17, this.reversedGeometries = false, this.shiftedPolygons = false, - - // this.objectComparator = _deepEqual, }); - bool _compareTypes( - GeoJSONObject? g1, GeoJSONObject? g2) { - return g1 is T && g2 is T; + bool compare(GeoJSONObject? g1, GeoJSONObject? g2) { + if (g1 == null || g2 == null) { + return g1 == g2; + } else if (g1 is Point && g2 is Point) { + return _comparePoint(g1, g2); + } else if (g1 is LineString && g2 is LineString) { + return _compareLineString(g1, g2); + } else if (g1 is Polygon && g2 is Polygon) { + return _comparePolygon(g1, g2); + } else if (g1 is Feature && g2 is Feature) { + return _compareFeature(g1, g2); + } else if (g1 is FeatureCollection && g2 is FeatureCollection) { + return _compareFeatureCollection(g1, g2); + } else if (g1 is GeometryCollection && g2 is GeometryCollection) { + return _compareGeometryCollection(g1, g2); + } else if (g1 is MultiPoint && g2 is MultiPoint) { + return _compareMultiPoint(g1, g2); + } else if (g1 is MultiLineString && g2 is MultiLineString) { + return _compareMultiLineString(g1, g2); + } else if (g1 is MultiPolygon && g2 is MultiPolygon) { + return _compareMultiPolygon(g1, g2); + } else { + return false; + } } - bool compare(GeoJSONObject? g1, GeoJSONObject? g2) { - if (g1 == null && g2 == null) { - return true; - } else if (_compareTypes(g1, g2)) { - return _compareCoords( - (g1 as Point).coordinates, (g2 as Point).coordinates); - } else if (_compareTypes(g1, g2)) { - return _compareLine(g1 as LineString, g2 as LineString); - } else if (_compareTypes(g1, g2)) { - return _comparePolygon(g1 as Polygon, g2 as Polygon); - } else if (_compareTypes(g1, g2)) { - return compare((g1 as Feature).geometry, (g2 as Feature).geometry) && - g1.id == g2.id; - } else if (_compareTypes(g1, g2)) { - for (var i = 0; i < (g1 as FeatureCollection).features.length; i++) { - if (!compare(g1.features[i], (g2 as FeatureCollection).features[i])) { - return false; - } - } - return true; - } else if (_compareTypes(g1, g2)) { - return compare( - FeatureCollection( - features: (g1 as GeometryCollection) - .geometries - .map((e) => Feature(geometry: e)) - .toList(), - ), - FeatureCollection( - features: (g2 as GeometryCollection) - .geometries - .map((e) => Feature(geometry: e)) - .toList(), - ), - ); - } - // - else if (_compareTypes(g1, g2)) { - return compare( - FeatureCollection( - features: (g1 as MultiPoint) - .coordinates - .map((e) => Feature(geometry: Point(coordinates: e))) - .toList(), - ), - FeatureCollection( - features: (g2 as MultiPoint) - .coordinates - .map((e) => Feature(geometry: Point(coordinates: e))) - .toList()), - ); - } - // - else if (_compareTypes(g1, g2)) { - if ((g1 as MultiLineString).coordinates.length != - (g2 as MultiLineString).coordinates.length) { + bool _compareFeatureCollection( + FeatureCollection first, + FeatureCollection second, + ) { + if (first.features.length != second.features.length) { + return false; + } + for (var i = 0; i < first.features.length; i++) { + if (!compare(first.features[i], second.features[i])) { return false; } - for (var line = 0; line < g1.coordinates.length; line++) { - if (!compare(LineString(coordinates: g1.coordinates[line]), - LineString(coordinates: g2.coordinates[line]))) { - return false; - } + } + return true; + } + + bool _compareGeometryCollection( + GeometryCollection first, + GeometryCollection second, + ) { + if (first.geometries.length != second.geometries.length) { + return false; + } + for (var i = 0; i < first.geometries.length; i++) { + if (!compare(first.geometries[i], second.geometries[i])) { + return false; } - return true; - } - // - else if (_compareTypes(g1, g2)) { - return compare( - FeatureCollection( - features: (g1 as MultiPolygon) - .coordinates - .map((e) => Feature(geometry: Polygon(coordinates: e))) - .toList(), - ), - FeatureCollection( - features: (g2 as MultiPolygon) - .coordinates - .map( - (e) => Feature(geometry: Polygon(coordinates: e)), - ) - .toList()), - ); - } - // - else { + } + return true; + } + + bool _compareFeature(Feature feature1, Feature feature2) { + return feature1.id == feature2.id && + compare(feature1.geometry, feature2.geometry); + } + + bool _comparePoint(Point point1, Point point2) { + return _compareCoords(point1.coordinates, point2.coordinates); + } + + bool _compareMultiPoint(MultiPoint first, MultiPoint second) { + if (first.coordinates.length != second.coordinates.length) { return false; } + for (var i = 0; i < first.coordinates.length; i++) { + if (!_compareCoords(first.coordinates[i], second.coordinates[i])) { + return false; + } + } + return true; } - bool _compareLine(LineString line1, LineString line2) { + bool _compareLineString(LineString line1, LineString line2) { + if (line1.coordinates.length != line2.coordinates.length) return false; + if (!_compareCoords(line1.coordinates.first, line2.coordinates.first)) { - if (reversedGeometries) { + if (!reversedGeometries) { return false; } else { var newLine = LineString( @@ -133,7 +112,7 @@ class Equality { line1.coordinates.first, newLine.coordinates.first)) { return false; } else { - return _compareLine(line1, newLine); + return _compareLineString(line1, newLine); } } } else { @@ -146,78 +125,85 @@ class Equality { return true; } - bool _compareCoords(Position one, Position two) { - if (precision != 17) { - one = Position.of( - one.toList().map((e) => rounder.round(e, precision)).toList()); - two = Position.of( - two.toList().map((e) => rounder.round(e, precision)).toList()); + bool _compareMultiLineString(MultiLineString first, MultiLineString second) { + if (first.coordinates.length != second.coordinates.length) { + return false; } - return one == two; - } - - bool _comparePolygon(Polygon poly1, Polygon poly2) { - List> list1 = poly1 - .clone() - .coordinates - .map((e) => e.sublist(0, e.length - 1)) - .toList(); - List> list2 = poly2 - .clone() - .coordinates - .map((e) => e.sublist(0, e.length - 1)) - .toList(); - - for (var i = 0; i < list1.length; i++) { - if (list1[i].length != list2[i].length) { + for (var i = 0; i < first.coordinates.length; i++) { + final firstLineString = LineString(coordinates: first.coordinates[i]); + final secondLineString = LineString(coordinates: second.coordinates[i]); + if (!compare(firstLineString, secondLineString)) { return false; } - for (var positionIndex = 0; - positionIndex < list1[i].length; - positionIndex++) { - if (reversedGeometries) { - if (shiftedPolygons) { - List> listReversed = poly2 - .clone() - .coordinates - .map((e) => e.sublist(0, e.length - 1)) - .toList() - .map((e) => e.reversed.toList()) - .toList(); - int diff = listReversed[i].indexOf(list1[i][0]); - if (!_compareCoords( - list1[i][positionIndex], - (listReversed[i][ - (listReversed[i].length + positionIndex + diff) % - listReversed[i].length]))) { - return false; - } - } else { - List> listReversed = poly2 - .clone() - .coordinates - .map((e) => e.sublist(0, e.length - 1)) - .toList() - .map((e) => e.reversed.toList()) - .toList(); - if (!_compareCoords( - list1[i][positionIndex], listReversed[i][positionIndex])) { + } + + return true; + } + + bool _comparePolygon(Polygon polygon1, Polygon polygon2) { + List> reverse(Polygon polygon) { + return polygon + .clone() + .coordinates + .map((e) => e.sublist(0, e.length - 1)) + .toList() + .map((e) => e.reversed.toList()) + .toList(); + } + + Position shift(Position first, List coords, int index) { + int diff = coords.indexOf(first); + final iShifted = (coords.length + index + diff) % coords.length; + return coords[iShifted]; + } + + List> deconstruct(Polygon polygon) { + return polygon + .clone() + .coordinates + .map((e) => e.sublist(0, e.length - 1)) + .toList(); + } + + List> linearRings1 = deconstruct(polygon1); + List> linearRings2 = deconstruct(polygon2); + + if (linearRings1.length != linearRings2.length) return false; + + for (var iRing = 0; iRing < linearRings1.length; iRing++) { + final coords1 = linearRings1[iRing]; + final coords2 = linearRings2[iRing]; + + if (coords1.length != coords2.length) return false; + + for (var iPosition = 0; iPosition < coords1.length; iPosition++) { + final position1 = coords1[iPosition]; + final position2 = coords2[iPosition]; + + if (!_compareCoords(position1, position2)) { + if (!reversedGeometries && !shiftedPolygons) { + return false; + } + + if (!reversedGeometries && shiftedPolygons) { + final shifted = shift(coords1.first, coords2, iPosition); + if (!_compareCoords(position1, shifted)) { return false; } } - } else { - if (shiftedPolygons) { - int diff = list2[i].indexOf(list1[i][0]); - if (!_compareCoords( - list1[i][positionIndex], - (list2[i][(list2[i].length + positionIndex + diff) % - list2[i].length]))) { + + if (reversedGeometries && shiftedPolygons) { + final reversed = reverse(polygon2)[iRing]; + final shifted = shift(coords1.first, reversed, iPosition); + if (!_compareCoords(position1, shifted)) { return false; } - } else { - if (!_compareCoords( - list1[i][positionIndex], list2[i][positionIndex])) { + } + + if (reversedGeometries && !shiftedPolygons) { + final reversed = reverse(polygon2)[iRing][iPosition]; + if (!_compareCoords(position1, reversed)) { return false; } } @@ -226,4 +212,29 @@ class Equality { } return true; } + + bool _compareMultiPolygon(MultiPolygon first, MultiPolygon second) { + if (first.coordinates.length != second.coordinates.length) { + return false; + } + + for (var i = 0; i < first.coordinates.length; i++) { + final firstPolygon = Polygon(coordinates: first.coordinates[i]); + final secondPolygon = Polygon(coordinates: second.coordinates[i]); + if (!compare(firstPolygon, secondPolygon)) { + return false; + } + } + return true; + } + + bool _compareCoords(Position one, Position two) { + if (precision != 17) { + one = Position.of( + one.toList().map((e) => rounder.round(e, precision)).toList()); + two = Position.of( + two.toList().map((e) => rounder.round(e, precision)).toList()); + } + return one == two; + } } diff --git a/test/context/helper.dart b/test/context/helper.dart index f786283..3dffa4f 100644 --- a/test/context/helper.dart +++ b/test/context/helper.dart @@ -1,25 +1,40 @@ -import 'package:turf/helpers.dart'; +// A List of builders that are similar to the way TurfJs creates GeoJSON +// objects. The idea is to make it easier to port JavaScript tests to Dart. +import 'package:turf/turf.dart'; -LineString lineString(List> coordinates) { - return LineString(coordinates: coordinates.toPositions()); +Feature lineString(List> coordinates, {dynamic id}) { + return Feature( + id: id, + geometry: LineString(coordinates: positions(coordinates)), + ); } -Point point(List coordinates) { +Point point(List coordinates) { return Point(coordinates: Position.of(coordinates)); } -Feature polygon(List>> coordinates) { +Position position(List coordinates) { + return Position.of(coordinates); +} + +List positions(List> coordinates) { + return coordinates.map((e) => position(e)).toList(growable: false); +} + +Feature polygon(List>> coordinates) { return Feature( - geometry: Polygon(coordinates: coordinates.toPositions()), + geometry: Polygon( + coordinates: coordinates + .map((element) => positions(element)) + .toList(growable: false)), ); } -extension PointsExtension on List> { - List toPositions() => - map((position) => Position.of(position)).toList(growable: false); +FeatureCollection featureCollection( + [List> features = const []]) { + return FeatureCollection(features: features); } -extension PolygonPointsExtensions on List>> { - List> toPositions() => - map((element) => element.toPositions()).toList(growable: false); +extension LineListExtension on List> { + List> clone() => map((element) => List.from(element)).toList(); } diff --git a/test/geojson_equality_test.dart b/test/geojson_equality_test.dart index 250b496..e495212 100644 --- a/test/geojson_equality_test.dart +++ b/test/geojson_equality_test.dart @@ -37,8 +37,246 @@ void main() { ); expect(result, true); }); + + test('same line, different amount of points (additional middle point)', () { + final result = eq.compare( + lineString([ + [100, -30], + [120, -30], + ]), + lineString([ + [100, -30], + [110, -30], + [120, -30], + ]), + ); + expect(result, false); + }); + + // ToDo: This should be resolved as part of data normalization and not + // handled during equality checks. + test('same line, different amount of points (end point duplicated)', () { + final result = eq.compare( + lineString([ + [100, -30], + [120, -30], + ]), + lineString([ + [100, -30], + [120, -30], + [120, -30], + ]), + ); + expect(result, false); + }); + + test('same line, different orientation', () { + final line1 = lineString([ + [100, -30], + [120, -30], + ]); + final line2 = lineString([ + [120, -30], + [100, -30], + ]); + final line3 = lineString([ + [0, 0], + [0, 5], + [5, 5], + [5, 0] + ]); + final line4 = lineString([ + [5, 0], + [5, 5], + [0, 5], + [0, 0] + ]); + + final defaultParam = Equality(); + expect(defaultParam.compare(line1, line1), true); + expect(defaultParam.compare(line1, line2), false); + expect(defaultParam.compare(line3, line4), false); + + final reversedFalse = Equality(reversedGeometries: false); + expect(reversedFalse.compare(line1, line1), true); + expect(reversedFalse.compare(line1, line2), false); + expect(reversedFalse.compare(line3, line4), false); + + final reversedTrue = Equality(reversedGeometries: true); + expect(reversedTrue.compare(line1, line1), true); + expect(reversedTrue.compare(line1, line2), true); + expect(reversedTrue.compare(line3, line4), true); + }); + + test('detect modification on lat, long, start and end point', () { + final original = [ + [100, -30], + [120, -30], + ]; + for (var point = 0; point < 2; point++) { + for (var coordinate = 0; coordinate < 2; coordinate++) { + final modified = original.clone(); + modified[point][coordinate] = 0; + final result = eq.compare(lineString(original), lineString(modified)); + expect(result, false); + } + } + }); + + test('detect difference in altitude', () { + expect( + eq.compare( + lineString([ + [100, -30, 100], + [120, -30], + ]), + lineString([ + [100, -30], + [120, -30], + ]), + ), + false, + ); + + expect( + eq.compare( + lineString([ + [100, -30], + [120, -30, 100], + ]), + lineString([ + [100, -30], + [120, -30, 120], + ]), + ), + false, + ); + }); + + test('same line with altitude', () { + expect( + eq.compare( + lineString([ + [100, -30, 100], + [120, -30, 40], + ]), + lineString([ + [100, -30, 100], + [120, -30, 40], + ]), + ), + true, + ); + }); + }); + + group('FeatureCollection Equality', () { + final eq = Equality(); + test('collections with different length are not equal', () { + expect( + eq.compare( + FeatureCollection(), + FeatureCollection(features: [Feature()]), + ), + false, + ); + + expect( + eq.compare( + FeatureCollection(features: [Feature()]), + FeatureCollection(), + ), + false, + ); + + expect( + eq.compare( + FeatureCollection(features: [Feature()]), + FeatureCollection(features: [Feature(), Feature()]), + ), + false, + ); + + expect( + eq.compare( + FeatureCollection(features: [Feature(), Feature()]), + FeatureCollection(features: [Feature()]), + ), + false, + ); + }); + + test('collections with same length are equal', () { + expect( + eq.compare( + FeatureCollection(), + FeatureCollection(), + ), + true, + ); + + expect( + eq.compare( + FeatureCollection(features: [Feature()]), + FeatureCollection(features: [Feature()]), + ), + true, + ); + + expect( + eq.compare( + FeatureCollection(features: [Feature(), Feature()]), + FeatureCollection(features: [Feature(), Feature()]), + ), + true, + ); + + expect( + eq.compare( + FeatureCollection(features: [ + Feature(geometry: Point(coordinates: Position(1, 1))) + ]), + FeatureCollection(features: [ + Feature(geometry: Point(coordinates: Position(1, 1))) + ]), + ), + true, + ); + }); + + test('order does matter', () { + expect( + eq.compare( + FeatureCollection(features: [ + Feature(id: '1'), + Feature(id: '2'), + ]), + FeatureCollection(features: [ + Feature(id: '2'), + Feature(id: '1'), + ]), + ), + false, + ); + }); + + test('different ids affect equality', () { + expect( + eq.compare( + FeatureCollection(features: [ + Feature(id: '1'), + ]), + FeatureCollection(features: [ + Feature(id: '2'), + ]), + ), + false, + ); + }); }); + group('LineString Equality', () {}); + group( 'Turf GeoJSONEquality', () { @@ -107,7 +345,7 @@ void main() { test( 'precision ${inFile.uri.pathSegments.last}', () { - Equality eq = Equality(precision: 5); + Equality eq = Equality(precision: 5, reversedGeometries: true); var outDir = Directory('./test/examples/out'); for (var outFile in outDir.listSync(recursive: true)) { if (outFile is File && outFile.path.endsWith('.geojson')) { @@ -115,7 +353,8 @@ void main() { inFile.uri.pathSegments.last) { GeoJSONObject outGeom = GeoJSONObject.fromJson( jsonDecode(outFile.readAsStringSync())); - expect(eq.compare(inGeom, outGeom), true); + expect(eq.compare(inGeom, outGeom), true, + reason: inFile.uri.pathSegments.last); } } }