Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
nielsenko committed Aug 28, 2023
1 parent 0ed4148 commit adb37a8
Show file tree
Hide file tree
Showing 4 changed files with 403 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
"finalizable",
"finalizer",
"fnum",
"geospatial",
"HRESULT",
"keepalive",
"loggable",
"mugaritz",
"nodoc",
"nullptr",
"posix",
Expand Down
78 changes: 74 additions & 4 deletions common/lib/src/realm_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -231,21 +231,75 @@ class GeoPoint implements GeoShape {
final double lat;
final double lng;

const GeoPoint(this.lat, this.lng);
GeoPoint(this.lat, this.lng) {
if (lat < -90 || lat > 90) throw ArgumentError.value(lat, 'lat', 'must be between -90 and 90');
if (lng < -180 || lng > 180) throw ArgumentError.value(lng, 'lng', 'must be between -180 and 180');
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! GeoPoint) return false;
return lat == other.lat && lng == other.lng;
}

@override
int get hashCode => Object.hash(lat, lng);

@override
String toString() => '[$lng, $lat]';
}

class GeoBox implements GeoShape {
final GeoPoint southWest;
final GeoPoint northEast;

const GeoBox(this.southWest, this.northEast);

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! GeoBox) return false;
return southWest == other.southWest && northEast == other.northEast;
}

@override
int get hashCode => Object.hash(southWest, northEast);

@override
String toString() => 'geoBox($southWest, $northEast)';
}

typedef GeoRing = List<GeoPoint>;

extension on GeoRing {
void validate() {
if (first != last) throw ArgumentError('Vertices must for a ring (first != last)');
if (length < 4) throw ArgumentError('Ring must have at least 3 different vertices');
}
}

class GeoPolygon implements GeoShape {
final List<GeoPoint> outerRing;
final List<List<GeoPoint>> holes;
final GeoRing outerRing;
final List<GeoRing> holes;

const GeoPolygon(this.outerRing, [this.holes = const []]);
GeoPolygon(this.outerRing, [this.holes = const []]) {
outerRing.validate();
for (final hole in holes) {
hole.validate();
}
}

@override
String toString() {
ringToString(GeoRing ring) => '{${ring.join(', ')}}';

final outerRingString = ringToString(outerRing);
if (holes.isEmpty) return 'geoPolygon($outerRingString)';

final holesString = holes.map(ringToString).join(', ');
return 'geoPolygon($outerRingString, $holesString)';
}
}

const _metersPerMile = 1609.344;
Expand All @@ -265,6 +319,9 @@ class GeoDistance implements Comparable<GeoDistance> {

@override
int compareTo(GeoDistance other) => radians.compareTo(other.radians);

@override
String toString() => '$radians';
}

extension DoubleToGeoDistance on double {
Expand All @@ -278,4 +335,17 @@ class GeoCircle implements GeoShape {
final GeoDistance radius;

const GeoCircle(this.center, this.radius);

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! GeoCircle) return false;
return center == other.center && radius == other.radius;
}

@override
int get hashCode => Object.hash(center, radius);

@override
String toString() => 'geoCircle($center, $radius)';
}
227 changes: 227 additions & 0 deletions test/geospatial_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
////////////////////////////////////////////////////////////////////////////////
//
// Copyright 2023 Realm Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////////
import 'dart:async';

import 'package:realm_common/realm_common.dart';
import 'package:test/test.dart' hide test, throws;

import '../lib/realm.dart';
import 'test.dart';

part 'geospatial_test.g.dart';

@RealmModel(ObjectType.embeddedObject)
class _Location {
final String type = 'Point';
List<double> coordinates = const [0, 0];

double get longitude => coordinates[0];
set longitude(double value) => coordinates[0] = value;

double get latitude => coordinates[1];
set latitude(double value) => coordinates[1] = value;
}

@RealmModel()
class _Restaurant {
@PrimaryKey()
late String name;
_Location? location;

@override
String toString() => name;
}

void createRestaurants(Realm realm) {
realm.write(() {
realm.add(Restaurant('Burger King', location: (0.0, 0.0).toLocation()));
});
}

extension on GeoPoint {
Location toLocation() {
return Location(coordinates: [lng, lat]);
}
}

extension on Location {
GeoPoint toGeoPoint() {
return GeoPoint(coordinates[1], coordinates[0]);
}
}

extension on num {
GeoDistance get m => GeoDistance.fromMeters(toDouble());
GeoDistance get km => (this * 1000).m;
}

extension on (num, num) {
GeoPoint toGeoPoint({bool reverse = false}) => reverse ? GeoPoint($2.toDouble(), $1.toDouble()) : GeoPoint($1.toDouble(), $2.toDouble());
Location toLocation() => toGeoPoint().toLocation();
}

GeoRing ring(Iterable<(num, num)> coords, {bool close = true, bool reverse = false}) =>
GeoRing.from(coords.followedBy(close ? [coords.first] : []).map((c) => c.toGeoPoint(reverse: reverse)));

Future<void> main([List<String>? args]) async {
await setupTests(args);

final noma = Restaurant('Noma', location: (55.682837071136916, 12.610534422524335).toLocation());
final theFatDuck = Restaurant('The Fat Duck', location: (51.508054146883474, -0.7017480029998424).toLocation());
final mugaritz = Restaurant('Mugaritz', location: (43.27291163851115, -1.9169972753911122).toLocation());

final realm = Realm(Configuration.inMemory([Location.schema, Restaurant.schema]));
realm.write(() => realm.addAll([noma, theFatDuck, mugaritz]));

final ringAroundNoma = ring([
(55.7, 12.7),
(55.7, 12.6),
(55.6, 12.6),
]);
final ringAroundTheFatDuck = ring([
(51.6, -0.7),
(51.5, -0.7),
(51.5, -0.8),
]);
final ringAroundMugaritz = ring([
(43.3, -2.0),
(43.3, -1.9),
(43.2, -1.9),
]);
// https://earth.google.com/earth/d/1yxJunwmJ8bOHVveoJZ_ProcCVO_VqYaz?usp=sharing
final ringAroundAll = ring([
(56.65398894416146, 14.28516468673617),
(56.27411809280813, 6.939337946654436),
(52.42582816187998, -1.988816029211967),
(49.09018453730319, -6.148020252081531),
(42.92138665921894, -6.397402529198644),
(41.49883413234565, -1.91041351386849),
(48.58429125105875, 1.196195056765141),
(52.7379048959241, 7.132994722232744),
(54.51275033599874, 11.0454979022267),
]);

for (final (shape, restaurants) in [
(GeoCircle(noma.location!.toGeoPoint(), 0.m), [noma]),
(GeoCircle(theFatDuck.location!.toGeoPoint(), 10.m), [theFatDuck]),
(GeoCircle(mugaritz.location!.toGeoPoint(), 10.m), [mugaritz]),
(GeoCircle(noma.location!.toGeoPoint(), 1000.km), [noma, theFatDuck]),
(GeoCircle(noma.location!.toGeoPoint(), 5000.km), [noma, theFatDuck, mugaritz]),
(GeoBox((55.6, 12.6).toGeoPoint(), (55.7, 12.7).toGeoPoint()), [noma]),
(GeoBox((51.5, -0.8).toGeoPoint(), (51.6, -0.7).toGeoPoint()), [theFatDuck]),
(GeoBox((43.2, -2.0).toGeoPoint(), (43.3, -1.9).toGeoPoint()), [mugaritz]),
(GeoBox((51.5, -0.8).toGeoPoint(), (55.7, 12.7).toGeoPoint()), [noma, theFatDuck]),
(GeoBox((43.2, -2.0).toGeoPoint(), (55.7, 12.7).toGeoPoint()), [noma, theFatDuck, mugaritz]),
(GeoPolygon(ringAroundNoma), [noma]),
(GeoPolygon(ringAroundTheFatDuck), [theFatDuck]),
(GeoPolygon(ringAroundMugaritz), [mugaritz]),
(
GeoPolygon([
noma.location!.toGeoPoint(),
theFatDuck.location!.toGeoPoint(),
mugaritz.location!.toGeoPoint(),
noma.location!.toGeoPoint(), // close it
]),
[], // corners not included (at least sometimes)
),
(
GeoPolygon(ringAroundAll),
[
noma,
theFatDuck,
mugaritz,
],
),
(
GeoPolygon(ringAroundAll, [ringAroundNoma]),
[
theFatDuck,
mugaritz,
],
),
(
GeoPolygon(ringAroundAll, [ringAroundNoma, ringAroundTheFatDuck]),
[
mugaritz,
],
),
(
GeoPolygon(ringAroundAll, [ringAroundNoma, ringAroundTheFatDuck, ringAroundMugaritz]),
[],
)
]) {
test('geo within $shape', () {
final results = realm.query<Restaurant>('location geoWithin $shape');
expect(results, unorderedEquals(restaurants));
// TODO: This is temporary until C-API support GeoShapes
expect(() => realm.query<Restaurant>('location geoWithin \$0', [shape]), throws<RealmException>('Property type'));
});
}

test('GeoPoint', () {
final p = GeoPoint(42, 12);
final l = p.toLocation();

expect(p.lat, l.latitude);
expect(p.lng, l.longitude);
expect(l.coordinates, [p.lng, p.lat]);
});

test('GeoPoint', () {
final validPoints = <(double, double)>[
(0.0, 0.0),
(double.minPositive, 0),
];
for (final (lat, lng) in validPoints) {
final p = GeoPoint(lat, lng);
expect(p.lat, lat);
expect(p.lng, lng);
}
});

test('GeoPoint invalid args throws', () {
const latError = 'lat';
const lngError = 'lng';
final validPoints = <(double, double, String)>[
(-90.1, 0, latError),
(double.negativeInfinity, 0, latError),
(90.1, 0, latError),
(double.infinity, 0, latError),
// lng violations
(0, -180.1, lngError),
(0, double.negativeInfinity, lngError),
(0, 180.1, lngError),
(0, double.infinity, lngError),
];
for (final (lat, lng, error) in validPoints) {
expect(() => GeoPoint(lat, lng), throwsA(isA<ArgumentError>().having((e) => e.name, 'name', contains(error))));
}
});

test('GeoBox invalid args throws', () {});

test('GeoCircle invalid args throws', () {});

test('GeoPolygon invalid args throws', () {
expect(() => GeoPolygon(ring([(1, 1)])), throws<ArgumentError>('Ring must have at least 3 different'));
expect(() => GeoPolygon(ring([(1, 1), (2, 2)])), throws<ArgumentError>('Ring must have at least 3 different'));
expect(() => GeoPolygon(ring([(1, 1), (2, 2), (3, 3)], close: false)), throws<ArgumentError>('first != last'));
expect(() => GeoPolygon(ring([(1, 1), (2, 2), (3, 3), (4, 4)], close: false)), throws<ArgumentError>('first != last'));
});
}
Loading

0 comments on commit adb37a8

Please sign in to comment.