location: use LatLng instead of Tuple for coordinates, approximation when calling geocoder, locate without storing address line
This commit is contained in:
parent
5898c9052a
commit
78f5abc39c
12 changed files with 132 additions and 63 deletions
|
@ -8,13 +8,14 @@ import 'package:aves/services/image_file_service.dart';
|
|||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geocoder/geocoder.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
import 'package:path/path.dart' as ppath;
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
import 'mime_types.dart';
|
||||
|
||||
|
@ -295,9 +296,14 @@ class ImageEntry {
|
|||
|
||||
bool get isLocated => _addressDetails != null;
|
||||
|
||||
Tuple2<double, double> get latLng => isCatalogued ? Tuple2(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
||||
LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
||||
|
||||
String get geoUri => hasGps ? 'geo:${_catalogMetadata.latitude},${_catalogMetadata.longitude}?q=${_catalogMetadata.latitude},${_catalogMetadata.longitude}' : null;
|
||||
String get geoUri {
|
||||
if (!hasGps) return null;
|
||||
final latitude = roundToPrecision(_catalogMetadata.latitude, decimals: 6);
|
||||
final longitude = roundToPrecision(_catalogMetadata.longitude, decimals: 6);
|
||||
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
|
||||
}
|
||||
|
||||
List<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
|
||||
|
||||
|
@ -366,7 +372,6 @@ class ImageEntry {
|
|||
final address = addresses.first;
|
||||
addressDetails = AddressDetails(
|
||||
contentId: contentId,
|
||||
addressLine: address.addressLine,
|
||||
countryCode: address.countryCode,
|
||||
countryName: address.countryName,
|
||||
adminArea: address.adminArea,
|
||||
|
@ -378,11 +383,29 @@ class ImageEntry {
|
|||
}
|
||||
}
|
||||
|
||||
Future<String> findAddressLine() async {
|
||||
final latitude = _catalogMetadata?.latitude;
|
||||
final longitude = _catalogMetadata?.longitude;
|
||||
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null;
|
||||
|
||||
final coordinates = Coordinates(latitude, longitude);
|
||||
try {
|
||||
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
|
||||
if (addresses != null && addresses.isNotEmpty) {
|
||||
final address = addresses.first;
|
||||
return address.addressLine;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get shortAddress {
|
||||
if (!isLocated) return '';
|
||||
|
||||
// admin area examples: Seoul, Geneva, null
|
||||
// locality examples: Mapo-gu, Geneva, Annecy
|
||||
// `admin area` examples: Seoul, Geneva, null
|
||||
// `locality` examples: Mapo-gu, Geneva, Annecy
|
||||
return {
|
||||
_addressDetails.countryName,
|
||||
_addressDetails.adminArea,
|
||||
|
@ -390,12 +413,13 @@ class ImageEntry {
|
|||
}.where((part) => part != null && part.isNotEmpty).join(', ');
|
||||
}
|
||||
|
||||
bool search(String query) {
|
||||
if (bestTitle?.toUpperCase()?.contains(query) ?? false) return true;
|
||||
if (_catalogMetadata?.xmpSubjects?.toUpperCase()?.contains(query) ?? false) return true;
|
||||
if (_addressDetails?.addressLine?.toUpperCase()?.contains(query) ?? false) return true;
|
||||
return false;
|
||||
}
|
||||
bool search(String query) => {
|
||||
bestTitle,
|
||||
_catalogMetadata?.xmpSubjects,
|
||||
_addressDetails?.countryName,
|
||||
_addressDetails?.adminArea,
|
||||
_addressDetails?.locality,
|
||||
}.any((s) => s != null && s.toUpperCase().contains(query));
|
||||
|
||||
Future<void> _applyNewFields(Map newFields) async {
|
||||
final uri = newFields['uri'];
|
||||
|
|
|
@ -142,13 +142,12 @@ class OverlayMetadata {
|
|||
|
||||
class AddressDetails {
|
||||
final int contentId;
|
||||
final String addressLine, countryCode, countryName, adminArea, locality;
|
||||
final String countryCode, countryName, adminArea, locality;
|
||||
|
||||
String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
|
||||
|
||||
AddressDetails({
|
||||
this.contentId,
|
||||
this.addressLine,
|
||||
this.countryCode,
|
||||
this.countryName,
|
||||
this.adminArea,
|
||||
|
@ -160,7 +159,6 @@ class AddressDetails {
|
|||
}) {
|
||||
return AddressDetails(
|
||||
contentId: contentId ?? this.contentId,
|
||||
addressLine: addressLine,
|
||||
countryCode: countryCode,
|
||||
countryName: countryName,
|
||||
adminArea: adminArea,
|
||||
|
@ -171,7 +169,6 @@ class AddressDetails {
|
|||
factory AddressDetails.fromMap(Map map) {
|
||||
return AddressDetails(
|
||||
contentId: map['contentId'],
|
||||
addressLine: map['addressLine'] ?? '',
|
||||
countryCode: map['countryCode'] ?? '',
|
||||
countryName: map['countryName'] ?? '',
|
||||
adminArea: map['adminArea'] ?? '',
|
||||
|
@ -181,7 +178,6 @@ class AddressDetails {
|
|||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'contentId': contentId,
|
||||
'addressLine': addressLine,
|
||||
'countryCode': countryCode,
|
||||
'countryName': countryName,
|
||||
'adminArea': adminArea,
|
||||
|
@ -190,7 +186,7 @@ class AddressDetails {
|
|||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/utils/geo_utils.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
enum CoordinateFormat { dms, decimal }
|
||||
|
@ -15,12 +16,12 @@ extension ExtraCoordinateFormat on CoordinateFormat {
|
|||
}
|
||||
}
|
||||
|
||||
String format(Tuple2<double, double> latLng) {
|
||||
String format(LatLng latLng) {
|
||||
switch (this) {
|
||||
case CoordinateFormat.dms:
|
||||
return toDMS(latLng).join(', ');
|
||||
case CoordinateFormat.decimal:
|
||||
return [latLng.item1, latLng.item2].map((n) => n.toStringAsFixed(6)).join(', ');
|
||||
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
|
@ -30,15 +32,25 @@ mixin LocationMixin on SourceBase {
|
|||
final todo = byLocated[false] ?? [];
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
// cache known locations to avoid querying the geocoder unless necessary
|
||||
// measuring the time it takes to process ~3000 coordinates (with ~10% of duplicates)
|
||||
// does not clearly show whether it is an actual optimization,
|
||||
// as results vary wildly (durations in "min:sec"):
|
||||
// - with no cache: 06:17, 08:36, 08:34
|
||||
// - with cache: 08:28, 05:42, 08:03, 05:58
|
||||
// anyway, in theory it should help!
|
||||
final knownLocations = <Tuple2<double, double>, AddressDetails>{};
|
||||
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(entry.latLng, () => entry.addressDetails));
|
||||
// geocoder calls take between 150ms and 250ms
|
||||
// approximation and caching can reduce geocoder usage
|
||||
// for example, for a set of 2932 entries:
|
||||
// - 2476 calls (84%) when approximating to 6 decimal places (~10cm - individual humans)
|
||||
// - 2433 calls (83%) when approximating to 5 decimal places (~1m - individual trees, houses)
|
||||
// - 2277 calls (78%) when approximating to 4 decimal places (~10m - individual street, large buildings)
|
||||
// - 1521 calls (52%) when approximating to 3 decimal places (~100m - neighborhood, street)
|
||||
// - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
|
||||
// cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
|
||||
final latLngFactor = pow(10, 2);
|
||||
Tuple2 approximateLatLng(ImageEntry entry) {
|
||||
final lat = entry.catalogMetadata?.latitude;
|
||||
final lng = entry.catalogMetadata?.longitude;
|
||||
if (lat == null || lng == null) return null;
|
||||
return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round());
|
||||
}
|
||||
|
||||
final knownLocations = <Tuple2, AddressDetails>{};
|
||||
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
|
||||
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
|
@ -46,13 +58,14 @@ mixin LocationMixin on SourceBase {
|
|||
|
||||
final newAddresses = <AddressDetails>[];
|
||||
await Future.forEach<ImageEntry>(todo, (entry) async {
|
||||
if (knownLocations.containsKey(entry.latLng)) {
|
||||
entry.addressDetails = knownLocations[entry.latLng]?.copyWith(contentId: entry.contentId);
|
||||
final latLng = approximateLatLng(entry);
|
||||
if (knownLocations.containsKey(latLng)) {
|
||||
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||
} else {
|
||||
await entry.locate(background: true);
|
||||
// it is intended to insert `null` if the geocoder failed,
|
||||
// so that we skip geocoding of following entries with the same coordinates
|
||||
knownLocations[entry.latLng] = entry.addressDetails;
|
||||
knownLocations[latLng] = entry.addressDetails;
|
||||
}
|
||||
if (entry.isLocated) {
|
||||
newAddresses.add(entry.addressDetails);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
|
||||
class Constants {
|
||||
// as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
|
||||
|
@ -21,7 +21,7 @@ class Constants {
|
|||
static const String overlayUnknown = '—'; // em dash
|
||||
static const String infoUnknown = 'unknown';
|
||||
|
||||
static const pointNemo = Tuple2(-48.876667, -123.393333);
|
||||
static final pointNemo = LatLng(-48.876667, -123.393333);
|
||||
|
||||
static const int infoGroupMaxValueLength = 140;
|
||||
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
|
||||
String _decimal2sexagesimal(final double degDecimal) {
|
||||
double _round(final double value, {final int decimals = 6}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
||||
|
||||
List<int> _split(final double value) {
|
||||
// NumberFormat is necessary to create digit after comma if the value
|
||||
// has no decimal point (only necessary for browser)
|
||||
final tmp = NumberFormat('0.0#####').format(_round(value, decimals: 10)).split('.');
|
||||
final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.');
|
||||
return <int>[
|
||||
int.parse(tmp[0]).abs(),
|
||||
int.parse(tmp[1]),
|
||||
|
@ -21,14 +18,14 @@ String _decimal2sexagesimal(final double degDecimal) {
|
|||
final min = _split(minDecimal)[0];
|
||||
final sec = (minDecimal - min) * 60;
|
||||
|
||||
return '$deg° $min′ ${_round(sec, decimals: 2).toStringAsFixed(2)}″';
|
||||
return '$deg° $min′ ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}″';
|
||||
}
|
||||
|
||||
// return coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
List<String> toDMS(Tuple2<double, double> latLng) {
|
||||
List<String> toDMS(LatLng latLng) {
|
||||
if (latLng == null) return [];
|
||||
final lat = latLng.item1;
|
||||
final lng = latLng.item2;
|
||||
final lat = latLng.latitude;
|
||||
final lng = latLng.longitude;
|
||||
return [
|
||||
'${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}',
|
||||
'${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}',
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
final double _log2 = log(2);
|
||||
const double _piOver180 = pi / 180.0;
|
||||
|
||||
|
@ -9,6 +11,8 @@ double toRadians(num degrees) => degrees * _piOver180;
|
|||
|
||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor());
|
||||
|
||||
double roundToPrecision(final double value, {@required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
||||
|
||||
// e.g. x=12345, precision=3 should return 13000
|
||||
int ceilBy(num x, int precision) {
|
||||
final factor = pow(10, precision);
|
||||
|
|
|
@ -128,7 +128,6 @@ class _DbTabState extends State<DbTab> {
|
|||
Text('DB address:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'addressLine': '${data.addressLine}',
|
||||
'countryCode': '${data.countryCode}',
|
||||
'countryName': '${data.countryName}',
|
||||
'adminArea': '${data.adminArea}',
|
||||
|
|
|
@ -12,6 +12,7 @@ import 'package:aves/widgets/fullscreen/info/maps/google_map.dart';
|
|||
import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/maps/marker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class LocationSection extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
|
@ -79,11 +80,9 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
|
|||
final showMap = (_loadedUri == entry.uri) || (entry.hasGps && widget.visibleNotifier.value);
|
||||
if (showMap) {
|
||||
_loadedUri = entry.uri;
|
||||
var location = '';
|
||||
final filters = <LocationFilter>[];
|
||||
if (entry.isLocated) {
|
||||
final address = entry.addressDetails;
|
||||
location = address.addressLine;
|
||||
final country = address.countryName;
|
||||
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
|
||||
final place = address.place;
|
||||
|
@ -114,7 +113,8 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
|
|||
vsync: this,
|
||||
child: settings.infoMapStyle.isGoogleMaps
|
||||
? EntryGoogleMap(
|
||||
latLng: entry.latLng,
|
||||
// `LatLng` used by `google_maps_flutter` is not the one from `latlong` package
|
||||
latLng: Tuple2<double, double>(entry.latLng.latitude, entry.latLng.longitude),
|
||||
geoUri: entry.geoUri,
|
||||
initialZoom: settings.infoMapZoom,
|
||||
markerId: entry.uri ?? entry.path,
|
||||
|
@ -130,11 +130,7 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
|
|||
),
|
||||
),
|
||||
),
|
||||
if (entry.hasGps)
|
||||
InfoRowGroup(Map.fromEntries([
|
||||
MapEntry('Coordinates', settings.coordinateFormat.format(entry.latLng)),
|
||||
if (location.isNotEmpty) MapEntry('Address', location),
|
||||
])),
|
||||
if (entry.hasGps) _AddressInfoGroup(entry: entry),
|
||||
if (filters.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
|
||||
|
@ -160,6 +156,41 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
|
|||
void _handleChange() => setState(() {});
|
||||
}
|
||||
|
||||
class _AddressInfoGroup extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
|
||||
const _AddressInfoGroup({@required this.entry});
|
||||
|
||||
@override
|
||||
_AddressInfoGroupState createState() => _AddressInfoGroupState();
|
||||
}
|
||||
|
||||
class _AddressInfoGroupState extends State<_AddressInfoGroup> {
|
||||
Future<String> _addressLineLoader;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_addressLineLoader = entry.findAddressLine();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String>(
|
||||
future: _addressLineLoader,
|
||||
builder: (context, snapshot) {
|
||||
final address = !snapshot.hasError && snapshot.connectionState == ConnectionState.done ? snapshot.data : '';
|
||||
return InfoRowGroup({
|
||||
'Coordinates': settings.coordinateFormat.format(entry.latLng),
|
||||
if (address.isNotEmpty) 'Address': address,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
|
||||
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../location_section.dart';
|
||||
|
@ -18,16 +17,15 @@ class EntryLeafletMap extends StatefulWidget {
|
|||
final Size markerSize;
|
||||
final WidgetBuilder markerBuilder;
|
||||
|
||||
EntryLeafletMap({
|
||||
const EntryLeafletMap({
|
||||
Key key,
|
||||
Tuple2<double, double> latLng,
|
||||
this.latLng,
|
||||
this.geoUri,
|
||||
this.initialZoom,
|
||||
this.style,
|
||||
this.markerBuilder,
|
||||
this.markerSize,
|
||||
}) : latLng = LatLng(latLng.item1, latLng.item2),
|
||||
super(key: key);
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => EntryLeafletMapState();
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import 'package:aves/utils/geo_utils.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
void main() {
|
||||
test('Decimal degrees to DMS (sexagesimal)', () {
|
||||
expect(toDMS(Tuple2(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam
|
||||
expect(toDMS(Tuple2(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund
|
||||
expect(toDMS(Tuple2(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo
|
||||
expect(toDMS(Tuple2(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio
|
||||
expect(toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam
|
||||
expect(toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund
|
||||
expect(toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo
|
||||
expect(toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio
|
||||
expect(toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -21,6 +21,11 @@ void main() {
|
|||
expect(highestPowerOf2(-42), 0);
|
||||
});
|
||||
|
||||
test('rounding to a given precision after the decimal', () {
|
||||
expect(roundToPrecision(1.2345678, decimals: 3), 1.235);
|
||||
expect(roundToPrecision(0, decimals: 3), 0);
|
||||
});
|
||||
|
||||
test('rounding up to a given precision before the decimal', () {
|
||||
expect(ceilBy(12345.678, 3), 13000);
|
||||
expect(ceilBy(42, 3), 1000);
|
||||
|
|
Loading…
Reference in a new issue