geohash actor
This commit is contained in:
parent
f1e54f2722
commit
cabac5ec49
4 changed files with 193 additions and 0 deletions
69
lib/src/geo_actor.dart
Normal file
69
lib/src/geo_actor.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import 'package:actors/actors.dart';
|
||||||
|
import 'package:dart_geohash/dart_geohash.dart';
|
||||||
|
|
||||||
|
sealed class GeoMessage {
|
||||||
|
const GeoMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class EncodeLocation extends GeoMessage {
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final int precision;
|
||||||
|
const EncodeLocation({
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
this.precision = 9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DecodeGeohash extends GeoMessage {
|
||||||
|
final String geohash;
|
||||||
|
const DecodeGeohash(this.geohash);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class NeighborsOf extends GeoMessage {
|
||||||
|
final String geohash;
|
||||||
|
const NeighborsOf(this.geohash);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class GeoResult {
|
||||||
|
const GeoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class GeohashEncoded extends GeoResult {
|
||||||
|
final String geohash;
|
||||||
|
const GeohashEncoded(this.geohash);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class LocationDecoded extends GeoResult {
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
const LocationDecoded({required this.latitude, required this.longitude});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys: NORTH, NORTHEAST, EAST, SOUTHEAST, SOUTH, SOUTHWEST, WEST, NORTHWEST, CENTRAL
|
||||||
|
final class NeighborsResult extends GeoResult {
|
||||||
|
final Map<String, String> neighbors;
|
||||||
|
const NeighborsResult(this.neighbors);
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeoActor with Handler<GeoMessage, GeoResult> {
|
||||||
|
final _hasher = GeoHasher();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GeoResult> handle(GeoMessage message) => switch (message) {
|
||||||
|
EncodeLocation(:final latitude, :final longitude, :final precision) =>
|
||||||
|
Future.value(
|
||||||
|
GeohashEncoded(
|
||||||
|
_hasher.encode(longitude, latitude, precision: precision)),
|
||||||
|
),
|
||||||
|
DecodeGeohash(:final geohash) => Future.value(_decode(geohash)),
|
||||||
|
NeighborsOf(:final geohash) =>
|
||||||
|
Future.value(NeighborsResult(_hasher.neighbors(geohash))),
|
||||||
|
};
|
||||||
|
|
||||||
|
LocationDecoded _decode(String geohash) {
|
||||||
|
final coords = _hasher.decode(geohash); // [longitude, latitude]
|
||||||
|
return LocationDecoded(latitude: coords[1], longitude: coords[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,3 +3,4 @@ export 'src/relay_connection.dart';
|
||||||
export 'src/nostr_publish_actor.dart';
|
export 'src/nostr_publish_actor.dart';
|
||||||
export 'src/nostr_fetch_actor.dart';
|
export 'src/nostr_fetch_actor.dart';
|
||||||
export 'src/nostr_signer_actor.dart';
|
export 'src/nostr_signer_actor.dart';
|
||||||
|
export 'src/geo_actor.dart';
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ dependencies:
|
||||||
actors: ^0.11.0
|
actors: ^0.11.0
|
||||||
bip340: ^0.3.1
|
bip340: ^0.3.1
|
||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
|
dart_geohash: ^2.1.0
|
||||||
retry: ^3.1.2
|
retry: ^3.1.2
|
||||||
web_socket_channel: ^3.0.3
|
web_socket_channel: ^3.0.3
|
||||||
|
|
||||||
|
|
|
||||||
122
test/geo_actor_test.dart
Normal file
122
test/geo_actor_test.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import 'package:swarm/swarm.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late GeoActor actor;
|
||||||
|
|
||||||
|
setUp(() => actor = GeoActor());
|
||||||
|
|
||||||
|
group('GeoActor', () {
|
||||||
|
group('EncodeLocation', () {
|
||||||
|
test('returns GeohashEncoded with correct length', () async {
|
||||||
|
final result = await actor.handle(
|
||||||
|
const EncodeLocation(
|
||||||
|
latitude: 48.8566, longitude: 2.3522, precision: 6),
|
||||||
|
);
|
||||||
|
expect(result, isA<GeohashEncoded>());
|
||||||
|
expect((result as GeohashEncoded).geohash, hasLength(6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default precision is 9', () async {
|
||||||
|
final result = await actor.handle(
|
||||||
|
const EncodeLocation(latitude: 51.5074, longitude: -0.1278),
|
||||||
|
);
|
||||||
|
expect((result as GeohashEncoded).geohash, hasLength(9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'higher precision produces longer hash with lower precision as prefix',
|
||||||
|
() async {
|
||||||
|
const lat = 52.52, lon = 13.405;
|
||||||
|
final r6 = await actor.handle(
|
||||||
|
const EncodeLocation(latitude: lat, longitude: lon, precision: 6),
|
||||||
|
);
|
||||||
|
final r9 = await actor.handle(
|
||||||
|
const EncodeLocation(latitude: lat, longitude: lon, precision: 9),
|
||||||
|
);
|
||||||
|
final h6 = (r6 as GeohashEncoded).geohash;
|
||||||
|
final h9 = (r9 as GeohashEncoded).geohash;
|
||||||
|
expect(h9, startsWith(h6));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DecodeGeohash', () {
|
||||||
|
test('returns LocationDecoded', () async {
|
||||||
|
final result = await actor.handle(const DecodeGeohash('u33dbf'));
|
||||||
|
expect(result, isA<LocationDecoded>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decoded coordinates are within cell bounds for precision 6',
|
||||||
|
() async {
|
||||||
|
// precision-6 cell error: ±0.0034° lat, ±0.0017° lon
|
||||||
|
final result = await actor.handle(const DecodeGeohash('u33dbf'));
|
||||||
|
final loc = result as LocationDecoded;
|
||||||
|
expect(loc.latitude, inInclusiveRange(52.0, 53.0));
|
||||||
|
expect(loc.longitude, inInclusiveRange(13.0, 14.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roundtrip encode→decode recovers coordinates within cell error',
|
||||||
|
() async {
|
||||||
|
const lat = 48.8566, lon = 2.3522;
|
||||||
|
final encoded = await actor.handle(
|
||||||
|
const EncodeLocation(latitude: lat, longitude: lon, precision: 9),
|
||||||
|
);
|
||||||
|
final decoded = await actor.handle(
|
||||||
|
DecodeGeohash((encoded as GeohashEncoded).geohash),
|
||||||
|
);
|
||||||
|
final loc = decoded as LocationDecoded;
|
||||||
|
// precision-9 cell error: ±0.000021° lat, ±0.000021° lon
|
||||||
|
expect(loc.latitude, closeTo(lat, 0.001));
|
||||||
|
expect(loc.longitude, closeTo(lon, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NeighborsOf', () {
|
||||||
|
const directions = [
|
||||||
|
'NORTH',
|
||||||
|
'NORTHEAST',
|
||||||
|
'EAST',
|
||||||
|
'SOUTHEAST',
|
||||||
|
'SOUTH',
|
||||||
|
'SOUTHWEST',
|
||||||
|
'WEST',
|
||||||
|
'NORTHWEST',
|
||||||
|
'CENTRAL',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('returns NeighborsResult with 9 entries', () async {
|
||||||
|
final result = await actor.handle(const NeighborsOf('u33dbf'));
|
||||||
|
expect(result, isA<NeighborsResult>());
|
||||||
|
expect((result as NeighborsResult).neighbors, hasLength(9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('result contains all 8 compass directions plus CENTRAL', () async {
|
||||||
|
final result = await actor.handle(const NeighborsOf('u33dbf'));
|
||||||
|
expect(
|
||||||
|
(result as NeighborsResult).neighbors.keys,
|
||||||
|
containsAll(directions),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CENTRAL equals the input geohash', () async {
|
||||||
|
const hash = 'u33dbf';
|
||||||
|
final result = await actor.handle(const NeighborsOf(hash));
|
||||||
|
expect((result as NeighborsResult).neighbors['CENTRAL'], equals(hash));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all neighbor hashes have the same precision as input', () async {
|
||||||
|
const hash = 'u33dbf';
|
||||||
|
final result = await actor.handle(const NeighborsOf(hash));
|
||||||
|
for (final neighbor in (result as NeighborsResult).neighbors.values) {
|
||||||
|
expect(neighbor, hasLength(hash.length));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neighbor hashes are all distinct', () async {
|
||||||
|
final result = await actor.handle(const NeighborsOf('u33dbf'));
|
||||||
|
final values = (result as NeighborsResult).neighbors.values.toList();
|
||||||
|
expect(values.toSet(), hasLength(values.length));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue