diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt index 4002cadc8..fb66600ab 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt @@ -8,40 +8,44 @@ https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFFphotoshop.pdf */ object ExifTags { - private const val TAG_X_POSITION = 0x011e - private const val TAG_Y_POSITION = 0x011f - private const val TAG_T4_OPTIONS = 0x0124 - private const val TAG_T6_OPTIONS = 0x0125 - private const val TAG_COLOR_MAP = 0x0140 - private const val TAG_EXTRA_SAMPLES = 0x0152 - private const val TAG_SAMPLE_FORMAT = 0x0153 - private const val TAG_RATING_PERCENT = 0x4749 + private const val X_POSITION = 0x011e + private const val Y_POSITION = 0x011f + private const val T4_OPTIONS = 0x0124 + private const val T6_OPTIONS = 0x0125 + private const val COLOR_MAP = 0x0140 + private const val EXTRA_SAMPLES = 0x0152 + private const val SAMPLE_FORMAT = 0x0153 + private const val SMIN_SAMPLE_VALUE = 0x0154 + private const val SMAX_SAMPLE_VALUE = 0x0155 + private const val RATING_PERCENT = 0x4749 private const val SONY_RAW_FILE_TYPE = 0x7000 private const val SONY_TONE_CURVE = 0x7010 - private const val TAG_MATTEING = 0x80e3 + private const val MATTEING = 0x80e3 // sensing method (0x9217) redundant with sensing method (0xA217) - private const val TAG_SENSING_METHOD = 0x9217 - private const val TAG_IMAGE_SOURCE_DATA = 0x935c - private const val TAG_GDAL_METADATA = 0xa480 - private const val TAG_GDAL_NO_DATA = 0xa481 + private const val SENSING_METHOD = 0x9217 + private const val IMAGE_SOURCE_DATA = 0x935c + private const val GDAL_METADATA = 0xa480 + private const val GDAL_NO_DATA = 0xa481 private val tagNameMap = hashMapOf( - TAG_X_POSITION to "X Position", - TAG_Y_POSITION to "Y Position", - TAG_T4_OPTIONS to "T4 Options", - TAG_T6_OPTIONS to "T6 Options", - TAG_COLOR_MAP to "Color Map", - TAG_EXTRA_SAMPLES to "Extra Samples", - TAG_SAMPLE_FORMAT to "Sample Format", - TAG_RATING_PERCENT to "Rating Percent", + X_POSITION to "X Position", + Y_POSITION to "Y Position", + T4_OPTIONS to "T4 Options", + T6_OPTIONS to "T6 Options", + COLOR_MAP to "Color Map", + EXTRA_SAMPLES to "Extra Samples", + SAMPLE_FORMAT to "Sample Format", + SMIN_SAMPLE_VALUE to "S Min Sample Value", + SMAX_SAMPLE_VALUE to "S Max Sample Value", + RATING_PERCENT to "Rating Percent", SONY_RAW_FILE_TYPE to "Sony Raw File Type", SONY_TONE_CURVE to "Sony Tone Curve", - TAG_MATTEING to "Matteing", - TAG_SENSING_METHOD to "Sensing Method (0x9217)", - TAG_IMAGE_SOURCE_DATA to "Image Source Data", - TAG_GDAL_METADATA to "GDAL Metadata", - TAG_GDAL_NO_DATA to "GDAL No Data", + MATTEING to "Matteing", + SENSING_METHOD to "Sensing Method (0x9217)", + IMAGE_SOURCE_DATA to "Image Source Data", + GDAL_METADATA to "GDAL Metadata", + GDAL_NO_DATA to "GDAL No Data", ).apply { putAll(DngTags.tagNameMap) putAll(ExifGeoTiffTags.tagNameMap) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GeoTiffTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GeoTiffTags.kt index d80f09c10..e31cca426 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GeoTiffTags.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GeoTiffTags.kt @@ -9,16 +9,27 @@ object GeoTiffKeys { private const val CITATION = 0x0402 private const val GEOG_TYPE = 0x0800 private const val GEOG_CITATION = 0x0801 + private const val GEOG_GEODETIC_DATUM = 0x0802 + private const val GEOG_LINEAR_UNITS = 0x0804 private const val GEOG_ANGULAR_UNITS = 0x0806 + private const val GEOG_ELLIPSOID = 0x0808 + private const val GEOG_SEMI_MAJOR_AXIS = 0x0809 + private const val GEOG_SEMI_MINOR_AXIS = 0x080a + private const val GEOG_INV_FLATTENING = 0x080b private const val PROJ_CS_TYPE = 0x0c00 private const val PROJ_CS_CITATION = 0x0c01 private const val PROJECTION = 0x0c02 private const val PROJ_COORD_TRANS = 0x0c03 private const val PROJ_LINEAR_UNITS = 0x0c04 private const val PROJ_STD_PARALLEL_1 = 0x0c06 + private const val PROJ_STD_PARALLEL_2 = 0x0c07 private const val PROJ_NAT_ORIGIN_LONG = 0x0c08 + private const val PROJ_NAT_ORIGIN_LAT = 0x0c09 private const val PROJ_FALSE_EASTING = 0x0c0a private const val PROJ_FALSE_NORTHING = 0x0c0b + private const val PROJ_SCALE_AT_NAT_ORIGIN = 0x0c14 + private const val PROJ_AZIMUTH_ANGLE = 0x0c16 + private const val VERTICAL_UNITS = 0x1003 private val tagNameMap = hashMapOf( GEOTIFF_VERSION to "GeoTIFF Version", @@ -27,16 +38,27 @@ object GeoTiffKeys { CITATION to "Citation", GEOG_TYPE to "Geographic Type", GEOG_CITATION to "Geographic Citation", + GEOG_GEODETIC_DATUM to "Geographic Geodetic Datum", + GEOG_LINEAR_UNITS to "Geographic Linear Units", GEOG_ANGULAR_UNITS to "Geographic Angular Units", + GEOG_ELLIPSOID to "Geographic Ellipsoid", + GEOG_SEMI_MAJOR_AXIS to "Semi-major axis", + GEOG_SEMI_MINOR_AXIS to "Semi-minor axis", + GEOG_INV_FLATTENING to "Inv. Flattening", PROJ_CS_TYPE to "Projected Coordinate System Type", PROJ_CS_CITATION to "Projected Coordinate System Citation", PROJECTION to "Projection", PROJ_COORD_TRANS to "Projected Coordinate Transform", PROJ_LINEAR_UNITS to "Projection Linear Units", PROJ_STD_PARALLEL_1 to "Projection Standard Parallel 1", + PROJ_STD_PARALLEL_2 to "Projection Standard Parallel 2", PROJ_NAT_ORIGIN_LONG to "Projection Natural Origin Longitude", + PROJ_NAT_ORIGIN_LAT to "Projection Natural Origin Latitude", PROJ_FALSE_EASTING to "Projection False Easting", PROJ_FALSE_NORTHING to "Projection False Northing", + PROJ_SCALE_AT_NAT_ORIGIN to "Projection Scale at Natural Origin", + PROJ_AZIMUTH_ANGLE to "Projection Azimuth Angle", + VERTICAL_UNITS to "Vertical Units", ) fun getTagName(tag: Int): String? { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f5d38548b..a04930ee5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -86,6 +86,7 @@ "entryActionPrint": "Print", "entryActionShare": "Share", "entryActionViewSource": "View source", + "entryActionShowGeoTiffOnMap": "Show as map overlay", "entryActionViewMotionPhotoVideo": "Open Motion Photo", "entryActionEdit": "Edit", "entryActionOpen": "Open with", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 313bdb2e1..783dcabc5 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -10,6 +10,8 @@ enum EntryInfoAction { editRating, editTags, removeMetadata, + // GeoTIFF + showGeoTiffOnMap, // motion photo viewMotionPhotoVideo, // debug @@ -23,6 +25,7 @@ class EntryInfoActions { EntryInfoAction.editRating, EntryInfoAction.editTags, EntryInfoAction.removeMetadata, + EntryInfoAction.showGeoTiffOnMap, EntryInfoAction.viewMotionPhotoVideo, ]; } @@ -41,6 +44,9 @@ extension ExtraEntryInfoAction on EntryInfoAction { return context.l10n.entryInfoActionEditTags; case EntryInfoAction.removeMetadata: return context.l10n.entryInfoActionRemoveMetadata; + // GeoTIFF + case EntryInfoAction.showGeoTiffOnMap: + return context.l10n.entryActionShowGeoTiffOnMap; // motion photo case EntryInfoAction.viewMotionPhotoVideo: return context.l10n.entryActionViewMotionPhotoVideo; @@ -77,6 +83,9 @@ extension ExtraEntryInfoAction on EntryInfoAction { return AIcons.editTags; case EntryInfoAction.removeMetadata: return AIcons.clear; + // GeoTIFF + case EntryInfoAction.showGeoTiffOnMap: + return AIcons.map; // motion photo case EntryInfoAction.viewMotionPhotoVideo: return AIcons.motionPhoto; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 0631fe22b..262d8d24c 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; +import 'package:aves/model/geotiff.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/trash.dart'; @@ -504,16 +505,35 @@ class AvesEntry { } catalogMetadata = CatalogMetadata(id: id); } else { + // pre-processing if (isVideo && (!isSized || durationMillis == 0)) { // exotic video that is not sized during loading final fields = await VideoMetadataFormatter.getLoadingMetadata(this); await applyNewFields(fields, persist: persist); } + + // cataloguing on platform catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background); + // post-processing if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) { catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this); } + if (isGeotiff && !hasGps) { + final info = await metadataFetchService.getGeoTiffInfo(this); + if (info != null) { + final center = MappedGeoTiff( + info: info, + entry: this, + ).center; + if (center != null) { + catalogMetadata = catalogMetadata?.copyWith( + latitude: center.latitude, + longitude: center.longitude, + ); + } + } + } } } diff --git a/lib/model/geotiff.dart b/lib/model/geotiff.dart new file mode 100644 index 000000000..f86c2a909 --- /dev/null +++ b/lib/model/geotiff.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_images.dart'; +import 'package:aves/ref/geotiff.dart'; +import 'package:aves/utils/geo_utils.dart'; +import 'package:aves/utils/math_utils.dart'; +import 'package:aves/widgets/common/map/tile.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:proj4dart/proj4dart.dart' as proj4; + +@immutable +class GeoTiffInfo extends Equatable { + final List? modelPixelScale, modelTiePoints, modelTransformation; + final int? projCSType, projLinearUnits; + + @override + List get props => [modelPixelScale, modelTiePoints, modelTransformation, projCSType, projLinearUnits]; + + const GeoTiffInfo({ + this.modelPixelScale, + this.modelTiePoints, + this.modelTransformation, + this.projCSType, + this.projLinearUnits, + }); + + factory GeoTiffInfo.fromMap(Map map) { + return GeoTiffInfo( + modelPixelScale: (map[GeoTiffExifTags.modelPixelScale] as List?)?.cast(), + modelTiePoints: (map[GeoTiffExifTags.modelTiePoints] as List?)?.cast(), + modelTransformation: (map[GeoTiffExifTags.modelTransformation] as List?)?.cast(), + projCSType: map[GeoTiffKeys.projCSType] as int?, + projLinearUnits: map[GeoTiffKeys.projLinearUnits] as int?, + ); + } +} + +class MappedGeoTiff { + final AvesEntry entry; + late LatLng? Function(Point pixel) pointToLatLng; + late Point? Function(Point smPoint) epsg3857ToPoint; + + static final mapServiceTileSize = (256 * ui.window.devicePixelRatio).round(); + static final mapServiceHelper = MapServiceHelper(mapServiceTileSize); + static final tileImagePaint = Paint(); + static final tileMissingPaint = Paint() + ..style = PaintingStyle.fill + ..color = Colors.black; + + MappedGeoTiff({ + required GeoTiffInfo info, + required this.entry, + }) { + pointToLatLng = (_) => null; + epsg3857ToPoint = (_) => null; + + // limitation: only support some UTM coordinate systems + final projCSType = info.projCSType; + final srcProj4 = GeoUtils.epsgToProj4(projCSType); + if (srcProj4 == null) { + debugPrint('unsupported projCSType=$projCSType'); + return; + } + + // limitation: only support model space values in units of meters + // TODO TLAD [geotiff] default from parsing proj4 instead of meter? + final projLinearUnits = info.projLinearUnits ?? GeoTiffUnits.linearMeter; + if (projLinearUnits != GeoTiffUnits.linearMeter) { + debugPrint('unsupported projLinearUnits=$projLinearUnits'); + return; + } + + // limitation: only support tie points, not transformation matrix + final modelTiePoints = info.modelTiePoints; + if (modelTiePoints == null) return; + + if (modelTiePoints.length < 6) return; + + // map image space (I,J,K) to model space (X,Y,Z) + final tpI = modelTiePoints[0]; + final tpJ = modelTiePoints[1]; + final tpK = modelTiePoints[2]; + final tpX = modelTiePoints[3]; + final tpY = modelTiePoints[4]; + final tpZ = modelTiePoints[5]; + + // limitation: expect 0,0,0,X,Y,0 + if (tpI != 0 || tpJ != 0 || tpK != 0 || tpZ != 0) return; + + final modelPixelScale = info.modelPixelScale; + if (modelPixelScale == null || modelPixelScale.length < 2) return; + + final xScale = modelPixelScale[0]; + final yScale = modelPixelScale[1]; + + final geoTiffProjection = proj4.Projection.parse(srcProj4); + final projToLatLng = proj4.ProjectionTuple( + fromProj: geoTiffProjection, + toProj: proj4.Projection.WGS84, + ); + pointToLatLng = (pixel) { + final srcPoint = proj4.Point( + x: tpX + pixel.x * xScale, + y: tpY - pixel.y * yScale, + ); + final destPoint = projToLatLng.forward(srcPoint); + + final latitude = destPoint.y; + final longitude = destPoint.x; + if (latitude >= -90.0 && latitude <= 90.0 && longitude >= -180.0 && longitude <= 180.0) { + return LatLng(latitude, longitude); + } + return null; + }; + + final projFromMapService = proj4.ProjectionTuple( + fromProj: proj4.Projection.GOOGLE, + toProj: geoTiffProjection, + ); + epsg3857ToPoint = (smPoint) { + final srcPoint = proj4.Point(x: smPoint.x, y: smPoint.y); + final destPoint = projFromMapService.forward(srcPoint); + return Point(((destPoint.x - tpX) / xScale).round(), -((destPoint.y - tpY) / yScale).round()); + }; + } + + Future getTile(int tx, int ty, int? zoomLevel) async { + zoomLevel ??= 0; + + // global projected coordinates in meters (EPSG:3857 Spherical Mercator) + final tileTopLeft3857 = mapServiceHelper.tileTopLeft(tx, ty, zoomLevel); + final tileBottomRight3857 = mapServiceHelper.tileTopLeft(tx + 1, ty + 1, zoomLevel); + + // image region coordinates in pixels + final tileTopLeftPx = epsg3857ToPoint(tileTopLeft3857); + final tileBottomRightPx = epsg3857ToPoint(tileBottomRight3857); + if (tileTopLeftPx == null || tileBottomRightPx == null) return null; + + final tileLeft = tileTopLeftPx.x; + final tileRight = tileBottomRightPx.x; + final tileTop = tileTopLeftPx.y; + final tileBottom = tileBottomRightPx.y; + + final regionLeft = tileLeft.clamp(0, width); + final regionRight = tileRight.clamp(0, width); + final regionTop = tileTop.clamp(0, height); + final regionBottom = tileBottom.clamp(0, height); + + final regionWidth = regionRight - regionLeft; + final regionHeight = regionBottom - regionTop; + if (regionWidth == 0 || regionHeight == 0) return null; + + final tileXScale = (tileRight - tileLeft) / mapServiceTileSize; + final sampleSize = max(1, highestPowerOf2(tileXScale)); + final region = entry.getRegion( + sampleSize: sampleSize, + region: Rectangle(regionLeft, regionTop, regionWidth, regionHeight), + ); + + final imageInfoCompleter = Completer(); + final imageStream = region.resolve(ImageConfiguration.empty); + final imageStreamListener = ImageStreamListener((image, synchronousCall) { + imageInfoCompleter.complete(image); + }, onError: imageInfoCompleter.completeError); + imageStream.addListener(imageStreamListener); + ImageInfo? regionImageInfo; + try { + regionImageInfo = await imageInfoCompleter.future; + } catch (error) { + debugPrint('failed to get image for region=$region with error=$error'); + } + imageStream.removeListener(imageStreamListener); + + final imageOffset = Offset( + regionLeft > tileLeft ? (regionLeft - tileLeft).toDouble() : 0, + regionTop > tileTop ? (regionTop - tileTop).toDouble() : 0, + ); + final tileImageScaleX = (tileRight - tileLeft) / mapServiceTileSize; + final tileImageScaleY = (tileBottom - tileTop) / mapServiceTileSize; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(1 / tileImageScaleX, 1 / tileImageScaleY); + if (regionImageInfo != null) { + final s = sampleSize.toDouble(); + canvas.scale(s, s); + canvas.drawImage(regionImageInfo.image, imageOffset / s, tileImagePaint); + canvas.scale(1 / s, 1 / s); + } else { + // fallback to show area + canvas.drawRect( + Rect.fromLTWH( + imageOffset.dx, + imageOffset.dy, + regionWidth.toDouble(), + regionHeight.toDouble(), + ), + tileMissingPaint, + ); + } + canvas.scale(tileImageScaleX, tileImageScaleY); + + final picture = recorder.endRecording(); + final tileImage = await picture.toImage(mapServiceTileSize, mapServiceTileSize); + final byteData = await tileImage.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return null; + + return MapTile( + width: tileImage.width, + height: tileImage.height, + data: byteData.buffer.asUint8List(), + ); + } + + int get width => entry.width; + + int get height => entry.height; + + bool get canOverlay => center != null; + + LatLng? get center => pointToLatLng(Point((width / 2).round(), (height / 2).round())); + + LatLng? get topLeft => pointToLatLng(const Point(0, 0)); + + LatLng? get bottomRight => pointToLatLng(Point(width, height)); +} diff --git a/lib/model/metadata/catalog.dart b/lib/model/metadata/catalog.dart index 2c380018f..85ed4ef4e 100644 --- a/lib/model/metadata/catalog.dart +++ b/lib/model/metadata/catalog.dart @@ -55,6 +55,8 @@ class CatalogMetadata { int? dateMillis, bool? isMultiPage, int? rotationDegrees, + double? latitude, + double? longitude, }) { return CatalogMetadata( id: id ?? this.id, @@ -68,8 +70,8 @@ class CatalogMetadata { rotationDegrees: rotationDegrees ?? this.rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, - latitude: latitude, - longitude: longitude, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, rating: rating, ); } diff --git a/lib/ref/geotiff.dart b/lib/ref/geotiff.dart index 57efc3a70..d8c5decc8 100644 --- a/lib/ref/geotiff.dart +++ b/lib/ref/geotiff.dart @@ -11,9 +11,19 @@ class GeoTiffKeys { static const int modelType = 0x0400; static const int rasterType = 0x0401; static const int geographicType = 0x0800; + static const int geogGeodeticDatum = 0x0802; static const int geogAngularUnits = 0x0806; + static const int geogEllipsoid = 0x0808; static const int projCSType = 0x0c00; static const int projection = 0x0c02; static const int projCoordinateTransform = 0x0c03; static const int projLinearUnits = 0x0c04; + static const int verticalUnits = 0x1003; +} + +class GeoTiffUnits { + static const int linearMeter = 9001; + static const int linearFootUSSurvey = 9003; + + double footUSSurveyToMeter(double input) => input * 1200 / 3937; } diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index c3b397cc4..ad756bcf5 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,4 +1,5 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/geotiff.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/fields.dart'; import 'package:aves/model/metadata/overlay.dart'; @@ -19,6 +20,8 @@ abstract class MetadataFetchService { Future getOverlayMetadata(AvesEntry entry); + Future getGeoTiffInfo(AvesEntry entry); + Future getMultiPageInfo(AvesEntry entry); Future getPanoramaInfo(AvesEntry entry); @@ -117,6 +120,23 @@ class PlatformMetadataFetchService implements MetadataFetchService { return null; } + @override + Future getGeoTiffInfo(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('getGeoTiffInfo', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }) as Map; + return GeoTiffInfo.fromMap(result); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } + @override Future getMultiPageInfo(AvesEntry entry) async { try { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index eeabd06ee..833611a76 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -24,6 +24,7 @@ class AIcons { static const IconData location = Icons.place_outlined; static const IconData locationUnlocated = Icons.location_off_outlined; static const IconData mainStorage = Icons.smartphone_outlined; + static const IconData opacity = Icons.opacity; static const IconData privacy = MdiIcons.shieldAccountOutline; static const IconData rating = Icons.star_border_outlined; static const IconData ratingFull = Icons.star; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 8cdfa25cc..b84506d91 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -317,6 +317,11 @@ class Constants { license: 'Apache 2.0', sourceUrl: 'https://github.com/DavBfr/dart_pdf', ), + Dependency( + name: 'Proj4dart', + license: 'MIT', + sourceUrl: 'https://github.com/maRci002/proj4dart', + ), Dependency( name: 'Stack Trace', license: 'BSD 3-Clause', diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart index f8ca04e33..a82a1ff2e 100644 --- a/lib/utils/geo_utils.dart +++ b/lib/utils/geo_utils.dart @@ -56,4 +56,98 @@ class GeoUtils { final east = ne.longitude; return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east)); } + + // cf https://epsg.io/EPSGCODE.proj4 + // cf https://github.com/stevage/epsg + // cf https://github.com/maRci002/proj4dart/blob/master/test/data/all_proj4_defs.dart + static String? epsgToProj4(int? epsg) { + // `32767` refers to user defined values + if (epsg == null || epsg == 32767) return null; + + if (3038 <= epsg && epsg <= 3051) { + // ETRS89 / UTM (N-E) + final zone = epsg - 3012; + return '+proj=utm +zone=$zone +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'; + } else if (26700 <= epsg && epsg <= 26799) { + // US State Plane (NAD27): 267xx/320xx + if (26703 <= epsg && epsg <= 26722) { + final zone = epsg - 26700; + return '+proj=utm +zone=$zone +datum=NAD27 +units=m +no_defs'; + } + // NAD27 datum requires loading `nadgrids` for accurate transformation: + // cf https://github.com/proj4js/proj4js/pull/363 + // cf https://github.com/maRci002/proj4dart/issues/8 + if (epsg == 26746) { + // NAD27 / California zone VI + return '+proj=lcc +lat_1=33.88333333333333 +lat_2=32.78333333333333 +lat_0=32.16666666666666 +lon_0=-116.25 +x_0=609601.2192024384 +y_0=0 +datum=NAD27 +units=us-ft +no_defs'; + } else if (epsg == 26771) { + // NAD27 / Illinois East + return '+proj=tmerc +lat_0=36.66666666666666 +lon_0=-88.33333333333333 +k=0.9999749999999999 +x_0=152400.3048006096 +y_0=0 +datum=NAD27 +units=us-ft +no_defs'; + } + } else if ((26900 <= epsg && epsg <= 26999) || (32100 <= epsg && epsg <= 32199)) { + // US State Plane (NAD83): 269xx/321xx + if (epsg == 26966) { + // NAD83 Georgia East + return '+proj=tmerc +lat_0=30 +lon_0=-82.16666666666667 +k=0.9999 +x_0=200000 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'; + } + } else if (32200 <= epsg && epsg <= 32299) { + // WGS72 / UTM northern hemisphere: 322zz where zz is UTM zone number + final zone = epsg - 32200; + return '+proj=utm +zone=$zone +ellps=WGS72 +towgs84=0,0,4.5,0,0,0.554,0.2263 +units=m +no_defs'; + } else if (32300 <= epsg && epsg <= 32399) { + // WGS72 / UTM southern hemisphere: 323zz where zz is UTM zone number + final zone = epsg - 32300; + return '+proj=utm +zone=$zone +south +ellps=WGS72 +towgs84=0,0,4.5,0,0,0.554,0.2263 +units=m +no_defs'; + } else if (32400 <= epsg && epsg <= 32460) { + // WGS72BE / UTM northern hemisphere: 324zz where zz is UTM zone number + final zone = epsg - 32400; + return '+proj=utm +zone=$zone +ellps=WGS72 +towgs84=0,0,1.9,0,0,0.814,-0.38 +units=m +no_defs'; + } else if (32500 <= epsg && epsg <= 32599) { + // WGS72BE / UTM southern hemisphere: 325zz where zz is UTM zone number + final zone = epsg - 32500; + return '+proj=utm +zone=$zone +south +ellps=WGS72 +towgs84=0,0,1.9,0,0,0.814,-0.38 +units=m +no_defs'; + } else if (32600 <= epsg && epsg <= 32699) { + // WGS84 / UTM northern hemisphere: 326zz where zz is UTM zone number + final zone = epsg - 32600; + return '+proj=utm +zone=$zone +datum=WGS84 +units=m +no_defs'; + } else if (32700 <= epsg && epsg <= 32799) { + // WGS84 / UTM southern hemisphere: 327zz where zz is UTM zone number + final zone = epsg - 32700; + return '+proj=utm +zone=$zone +south +datum=WGS84 +units=m +no_defs'; + } + + return null; + } +} + +// cf https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/ +class MapServiceHelper { + final int tileSize; + late final double initialResolution, originShift; + + MapServiceHelper(this.tileSize) { + initialResolution = 2 * pi * 6378137 / tileSize; + originShift = 2 * pi * 6378137 / 2.0; + } + + int matrixSize(int zoomLevel) { + return 1 << zoomLevel; + } + + Point pixelsToMeters(double px, double py, int zoomLevel) { + double res = resolution(zoomLevel); + double mx = px * res - originShift; + double my = -py * res + originShift; + return Point(mx, my); + } + + double resolution(int zoomLevel) { + return initialResolution / matrixSize(zoomLevel); + } + + Point tileTopLeft(int tx, int ty, int zoomLevel) { + final px = tx * tileSize; + final py = ty * tileSize; + return pixelsToMeters(px.toDouble(), py.toDouble(), zoomLevel); + } } diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 93202ab46..8eeb720bc 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/geotiff.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/settings.dart'; @@ -33,6 +34,8 @@ class GeoMap extends StatefulWidget { final LatLng? initialCenter; final ValueNotifier isAnimatingNotifier; final ValueNotifier? dotLocationNotifier; + final ValueNotifier? overlayOpacityNotifier; + final MappedGeoTiff? overlayEntry; final UserZoomChangeCallback? onUserZoomChange; final void Function(LatLng location)? onMapTap; final MarkerTapCallback? onMarkerTap; @@ -49,6 +52,8 @@ class GeoMap extends StatefulWidget { this.initialCenter, required this.isAnimatingNotifier, this.dotLocationNotifier, + this.overlayOpacityNotifier, + this.overlayEntry, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -176,6 +181,8 @@ class _GeoMapState extends State { markerClusterBuilder: _buildMarkerClusters, markerWidgetBuilder: _buildMarkerWidget, dotLocationNotifier: widget.dotLocationNotifier, + overlayOpacityNotifier: widget.overlayOpacityNotifier, + overlayEntry: widget.overlayEntry, onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, @@ -199,6 +206,8 @@ class _GeoMapState extends State { DotMarker.diameter + ImageMarker.outerBorderWidth * 2, DotMarker.diameter + ImageMarker.outerBorderWidth * 2, ), + overlayOpacityNotifier: widget.overlayOpacityNotifier, + overlayEntry: widget.overlayEntry, onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, @@ -262,12 +271,27 @@ class _GeoMapState extends State { } ZoomedBounds _initBounds() { - final initialCenter = widget.initialCenter; - final points = initialCenter != null ? {initialCenter} : entries.map((v) => v.latLng!).toSet(); - final bounds = ZoomedBounds.fromPoints( - points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, - collocationZoom: settings.infoMapZoom, - ); + ZoomedBounds? bounds; + + final overlayEntry = widget.overlayEntry; + if (overlayEntry != null) { + final corner1 = overlayEntry.topLeft; + final corner2 = overlayEntry.bottomRight; + if (corner1 != null && corner2 != null) { + bounds = ZoomedBounds.fromPoints( + points: {corner1, corner2}, + ); + } + } + if (bounds == null) { + final initialCenter = widget.initialCenter; + final points = initialCenter != null ? {initialCenter} : entries.map((v) => v.latLng!).toSet(); + bounds = ZoomedBounds.fromPoints( + points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, + collocationZoom: settings.infoMapZoom, + ); + } + return bounds.copyWith( zoom: max(bounds.zoom, minInitialZoom), ); diff --git a/lib/widgets/common/map/google/geotiff_tile_provider.dart b/lib/widgets/common/map/google/geotiff_tile_provider.dart new file mode 100644 index 000000000..72d5ccb03 --- /dev/null +++ b/lib/widgets/common/map/google/geotiff_tile_provider.dart @@ -0,0 +1,17 @@ +import 'package:aves/model/geotiff.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +class GeoTiffTileProvider extends TileProvider { + MappedGeoTiff overlayEntry; + + GeoTiffTileProvider(this.overlayEntry); + + @override + Future getTile(int x, int y, int? zoom) async { + final tile = await overlayEntry.getTile(x, y, zoom); + if (tile != null) { + return Tile(tile.width, tile.height, tile.data); + } + return TileProvider.noTile; + } +} diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 1cf44550d..a5f9c620f 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:aves/model/entry_images.dart'; +import 'package:aves/model/geotiff.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/map/buttons.dart'; @@ -9,6 +10,7 @@ import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; +import 'package:aves/widgets/common/map/google/geotiff_tile_provider.dart'; import 'package:aves/widgets/common/map/google/marker_generator.dart'; import 'package:aves/widgets/common/map/marker.dart'; import 'package:aves/widgets/common/map/theme.dart'; @@ -27,6 +29,8 @@ class EntryGoogleMap extends StatefulWidget { final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; final ValueNotifier? dotLocationNotifier; + final ValueNotifier? overlayOpacityNotifier; + final MappedGeoTiff? overlayEntry; final UserZoomChangeCallback? onUserZoomChange; final void Function(ll.LatLng location)? onMapTap; final void Function(GeoEntry geoEntry)? onMarkerTap; @@ -43,6 +47,8 @@ class EntryGoogleMap extends StatefulWidget { required this.markerClusterBuilder, required this.markerWidgetBuilder, required this.dotLocationNotifier, + this.overlayOpacityNotifier, + this.overlayEntry, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -169,54 +175,69 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse }); final interactive = context.select((v) => v.interactive); + final overlayEntry = widget.overlayEntry; return ValueListenableBuilder( valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null), builder: (context, dotLocation, child) { - return GoogleMap( - initialCameraPosition: CameraPosition( - bearing: -bounds.rotation, - target: _toGoogleLatLng(bounds.projectedCenter), - zoom: bounds.zoom, - ), - onMapCreated: (controller) async { - _googleMapController = controller; - final zoom = await controller.getZoomLevel(); - await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation); - if (mounted) { - setState(() {}); - } + return ValueListenableBuilder( + valueListenable: widget.overlayOpacityNotifier ?? ValueNotifier(1), + builder: (context, overlayOpacity, child) { + return GoogleMap( + initialCameraPosition: CameraPosition( + bearing: -bounds.rotation, + target: _toGoogleLatLng(bounds.projectedCenter), + zoom: bounds.zoom, + ), + onMapCreated: (controller) async { + _googleMapController = controller; + final zoom = await controller.getZoomLevel(); + await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation); + if (mounted) { + setState(() {}); + } + }, + // compass disabled to use provider agnostic controls + compassEnabled: false, + mapToolbarEnabled: false, + mapType: _toMapType(widget.style), + minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom), + rotateGesturesEnabled: true, + scrollGesturesEnabled: interactive, + // zoom controls disabled to use provider agnostic controls + zoomControlsEnabled: false, + zoomGesturesEnabled: interactive, + // lite mode disabled because it lacks camera animation + liteModeEnabled: false, + // tilt disabled to match leaflet + tiltGesturesEnabled: false, + myLocationEnabled: false, + myLocationButtonEnabled: false, + markers: { + ...markers, + if (dotLocation != null && _dotMarkerBitmap != null) + Marker( + markerId: const MarkerId('dot'), + anchor: const Offset(.5, .5), + consumeTapEvents: true, + icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), + position: _toGoogleLatLng(dotLocation), + zIndex: 1, + ) + }, + // TODO TLAD [geotiff] may use ground overlay instead when this is fixed: https://github.com/flutter/flutter/issues/26479 + tileOverlays: { + if (overlayEntry != null && overlayEntry.canOverlay) + TileOverlay( + tileOverlayId: TileOverlayId(overlayEntry.entry.uri), + tileProvider: GeoTiffTileProvider(overlayEntry), + transparency: 1 - overlayOpacity, + ), + }, + onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), + onCameraIdle: _onIdle, + onTap: (position) => widget.onMapTap?.call(_fromGoogleLatLng(position)), + ); }, - // compass disabled to use provider agnostic controls - compassEnabled: false, - mapToolbarEnabled: false, - mapType: _toMapType(widget.style), - minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom), - rotateGesturesEnabled: true, - scrollGesturesEnabled: interactive, - // zoom controls disabled to use provider agnostic controls - zoomControlsEnabled: false, - zoomGesturesEnabled: interactive, - // lite mode disabled because it lacks camera animation - liteModeEnabled: false, - // tilt disabled to match leaflet - tiltGesturesEnabled: false, - myLocationEnabled: false, - myLocationButtonEnabled: false, - markers: { - ...markers, - if (dotLocation != null && _dotMarkerBitmap != null) - Marker( - markerId: const MarkerId('dot'), - anchor: const Offset(.5, .5), - consumeTapEvents: true, - icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), - position: _toGoogleLatLng(dotLocation), - zIndex: 1, - ) - }, - onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), - onCameraIdle: _onIdle, - onTap: (position) => widget.onMapTap?.call(_fromGoogleLatLng(position)), ); }, ); diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 600a10f1b..764daffc8 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:aves/model/entry_images.dart'; +import 'package:aves/model/geotiff.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; @@ -30,6 +32,8 @@ class EntryLeafletMap extends StatefulWidget { final MarkerWidgetBuilder markerWidgetBuilder; final ValueNotifier? dotLocationNotifier; final Size markerSize, dotMarkerSize; + final ValueNotifier? overlayOpacityNotifier; + final MappedGeoTiff? overlayEntry; final UserZoomChangeCallback? onUserZoomChange; final void Function(LatLng location)? onMapTap; final void Function(GeoEntry geoEntry)? onMarkerTap; @@ -48,6 +52,8 @@ class EntryLeafletMap extends StatefulWidget { required this.dotLocationNotifier, required this.markerSize, required this.dotMarkerSize, + this.overlayOpacityNotifier, + this.overlayEntry, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -174,6 +180,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt ], children: [ _buildMapLayer(), + if (widget.overlayEntry != null) _buildOverlayImageLayer(), MarkerLayerWidget( options: MarkerLayerOptions( markers: markers, @@ -214,6 +221,32 @@ class _EntryLeafletMapState extends State with TickerProviderSt } } + Widget _buildOverlayImageLayer() { + final overlayEntry = widget.overlayEntry; + if (overlayEntry == null) return const SizedBox(); + + final corner1 = overlayEntry.topLeft; + final corner2 = overlayEntry.bottomRight; + if (corner1 == null || corner2 == null) return const SizedBox(); + + return ValueListenableBuilder( + valueListenable: widget.overlayOpacityNotifier ?? ValueNotifier(1), + builder: (context, overlayOpacity, child) { + return OverlayImageLayerWidget( + options: OverlayImageLayerOptions( + overlayImages: [ + OverlayImage( + bounds: LatLngBounds(corner1, corner2), + imageProvider: overlayEntry.entry.uriImage, + opacity: overlayOpacity, + ), + ], + ), + ); + }, + ); + } + void _onBoundsChange() => _debouncer(_onIdle); void _onIdle() { diff --git a/lib/widgets/common/map/tile.dart b/lib/widgets/common/map/tile.dart new file mode 100644 index 000000000..fdfb5cb2d --- /dev/null +++ b/lib/widgets/common/map/tile.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; + +@immutable +class MapTile { + final int width, height; + final Uint8List data; + + const MapTile({ + required this.width, + required this.height, + required this.data, + }); +} diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 7b1d2912e..d3859d1bd 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -28,13 +28,13 @@ class ThumbnailEntryOverlay extends StatelessWidget { const AnimatedImageIcon() else ...[ if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), - if (entry.isGeotiff) const GeoTiffIcon(), if (entry.is360) const SphericalImageIcon(), ], if (entry.isMultiPage) ...[ if (entry.isMotionPhoto && context.select((t) => t.showMotionPhoto)) const MotionPhotoIcon(), if (!entry.isMotionPhoto) MultiPageIcon(entry: entry), ], + if (entry.isGeotiff) const GeoTiffIcon(), if (entry.trashed && context.select((t) => t.showTrash)) TrashIcon(trashDaysLeft: entry.trashDaysLeft), ]; if (children.isEmpty) return const SizedBox(); diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index fb9092f53..8d096677c 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -4,12 +4,14 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/geotiff.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; @@ -36,11 +38,13 @@ class MapPage extends StatelessWidget { final CollectionLens collection; final AvesEntry? initialEntry; + final MappedGeoTiff? overlayEntry; const MapPage({ Key? key, required this.collection, this.initialEntry, + this.overlayEntry, }) : super(key: key); @override @@ -59,6 +63,7 @@ class MapPage extends StatelessWidget { child: _Content( collection: collection, initialEntry: initialEntry, + overlayEntry: overlayEntry, ), ), ), @@ -70,11 +75,13 @@ class MapPage extends StatelessWidget { class _Content extends StatefulWidget { final CollectionLens collection; final AvesEntry? initialEntry; + final MappedGeoTiff? overlayEntry; const _Content({ Key? key, required this.collection, this.initialEntry, + this.overlayEntry, }) : super(key: key); @override @@ -89,6 +96,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin final ValueNotifier _regionCollectionNotifier = ValueNotifier(null); final ValueNotifier _dotLocationNotifier = ValueNotifier(null); final ValueNotifier _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null); + final ValueNotifier _overlayOpacityNotifier = ValueNotifier(1); final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); final ValueNotifier _overlayVisible = ValueNotifier(true); late AnimationController _overlayAnimationController; @@ -205,6 +213,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin children: [ const SizedBox(height: 8), const Divider(height: 0), + _buildOverlayController(), _buildScroller(), ], ), @@ -224,9 +233,11 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin controller: _mapController, collectionListenable: openingCollection, entries: openingCollection.sortedEntries, - initialCenter: widget.initialEntry?.latLng, + initialCenter: widget.initialEntry?.latLng ?? widget.overlayEntry?.center, isAnimatingNotifier: _isPageAnimatingNotifier, dotLocationNotifier: _dotLocationNotifier, + overlayOpacityNotifier: _overlayOpacityNotifier, + overlayEntry: widget.overlayEntry, onMapTap: (_) => _toggleOverlay(), onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async { final index = regionCollection?.sortedEntries.indexOf(markerEntry); @@ -240,6 +251,32 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin ); } + Widget _buildOverlayController() { + if (widget.overlayEntry == null) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ValueListenableBuilder( + valueListenable: _overlayOpacityNotifier, + builder: (context, overlayOpacity, child) { + return Row( + children: [ + const Icon(AIcons.opacity), + Expanded( + child: Slider( + value: _overlayOpacityNotifier.value, + onChanged: (v) => _overlayOpacityNotifier.value = v, + min: 0, + max: 1, + ), + ), + ], + ); + }, + ), + ); + } + Widget _buildScroller() { return Stack( children: [ diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 1c3f06e4e..da976d791 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -4,10 +4,13 @@ import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/model/geotiff.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; @@ -34,6 +37,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: return true; + // GeoTIFF + case EntryInfoAction.showGeoTiffOnMap: + return entry.isGeotiff; // motion photo case EntryInfoAction.viewMotionPhotoVideo: return entry.isMotionPhoto; @@ -56,6 +62,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi return entry.canEditTags; case EntryInfoAction.removeMetadata: return entry.canRemoveMetadata; + // GeoTIFF + case EntryInfoAction.showGeoTiffOnMap: + return true; // motion photo case EntryInfoAction.viewMotionPhotoVideo: return true; @@ -84,6 +93,10 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.removeMetadata: await _removeMetadata(context); break; + // GeoTIFF + case EntryInfoAction.showGeoTiffOnMap: + await _showGeoTiffOnMap(context); + break; // motion photo case EntryInfoAction.viewMotionPhotoVideo: OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); @@ -135,6 +148,36 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.removeMetadata(types)); } + Future _showGeoTiffOnMap(BuildContext context) async { + final info = await metadataFetchService.getGeoTiffInfo(entry); + if (info == null) return; + + final mappedGeoTiff = MappedGeoTiff( + info: info, + entry: entry, + ); + if (!mappedGeoTiff.canOverlay) return; + + final baseCollection = collection; + if (baseCollection == null) return; + + await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: MapPage.routeName), + builder: (context) { + return MapPage( + collection: baseCollection.copyWith( + listenToSource: true, + fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != this.entry).toList(), + ), + overlayEntry: mappedGeoTiff, + ); + }, + ), + ); + } + void _goToDebug(BuildContext context) { Navigator.push( context, diff --git a/lib/widgets/viewer/info/metadata/geotiff.dart b/lib/widgets/viewer/info/metadata/geotiff.dart index e6648917c..3fdb84fca 100644 --- a/lib/widgets/viewer/info/metadata/geotiff.dart +++ b/lib/widgets/viewer/info/metadata/geotiff.dart @@ -1,6 +1,7 @@ import 'package:aves/ref/geotiff.dart'; class GeoTiffDirectory { + // TODO TLAD [geotiff] avoid string-based match static int? tagForName(String name) { switch (name) { case 'Model Type': @@ -9,8 +10,12 @@ class GeoTiffDirectory { return GeoTiffKeys.rasterType; case 'Geographic Type': return GeoTiffKeys.geographicType; + case 'Geographic Geodetic Datum': + return GeoTiffKeys.geogGeodeticDatum; case 'Geographic Angular Units': return GeoTiffKeys.geogAngularUnits; + case 'Geographic Ellipsoid': + return GeoTiffKeys.geogEllipsoid; case 'Projected Coordinate System Type': return GeoTiffKeys.projCSType; case 'Projection': @@ -19,6 +24,8 @@ class GeoTiffDirectory { return GeoTiffKeys.projCoordinateTransform; case 'Projection Linear Units': return GeoTiffKeys.projLinearUnits; + case 'Vertical Units': + return GeoTiffKeys.verticalUnits; default: return null; } @@ -32,6 +39,10 @@ class GeoTiffDirectory { return getRasterTypeDescription(v); case GeoTiffKeys.geographicType: return getGeographicTypeDescription(v); + case GeoTiffKeys.geogGeodeticDatum: + return getGeogGeodeticDatumDescription(v); + case GeoTiffKeys.geogEllipsoid: + return getGeogEllipsoidDescription(v); case GeoTiffKeys.projCSType: return getProjCSTypeDescription(v); case GeoTiffKeys.projection: @@ -40,6 +51,7 @@ class GeoTiffDirectory { return getProjCoordinateTransformDescription(v); case GeoTiffKeys.projLinearUnits: case GeoTiffKeys.geogAngularUnits: + case GeoTiffKeys.verticalUnits: return getGeoTiffUnitsDescription(v); default: return v; @@ -439,6 +451,410 @@ class GeoTiffDirectory { } } + static String getGeogGeodeticDatumDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 6001: + return 'Airy 1830'; + case 6002: + return 'Airy Modified 1849'; + case 6003: + return 'Australian National Spheroid'; + case 6004: + return 'Bessel 1841'; + case 6005: + return 'Bessel Modified'; + case 6006: + return 'Bessel Namibia'; + case 6007: + return 'Clarke 1858'; + case 6008: + return 'Clarke 1866'; + case 6009: + return 'Clarke 1866 Michigan'; + case 6010: + return 'Clarke 1880 Benoit'; + case 6011: + return 'Clarke 1880 IGN'; + case 6012: + return 'Clarke 1880 RGS'; + case 6013: + return 'Clarke 1880 Arc'; + case 6014: + return 'Clarke 1880 SGA 1922'; + case 6015: + return 'Everest 1830 1937 Adjustment'; + case 6016: + return 'Everest 1830 1967 Definition'; + case 6017: + return 'Everest 1830 1975 Definition'; + case 6018: + return 'Everest 1830 Modified'; + case 6019: + return 'GRS 1980'; + case 6020: + return 'Helmert 1906'; + case 6021: + return 'Indonesian National Spheroid'; + case 6022: + return 'International 1924'; + case 6023: + return 'International 1967'; + case 6024: + return 'Krassowsky 1960'; + case 6025: + return 'NWL9D'; + case 6026: + return 'NWL10D'; + case 6027: + return 'Plessis 1817'; + case 6028: + return 'Struve 1860'; + case 6029: + return 'War Office'; + case 6030: + return 'WGS84'; + case 6031: + return 'GEM10C'; + case 6032: + return 'OSU86F'; + case 6033: + return 'OSU91A'; + case 6034: + return 'Clarke 1880'; + case 6035: + return 'Sphere'; + case 6201: + return 'Adindan'; + case 6202: + return 'Australian Geodetic Datum 1966'; + case 6203: + return 'Australian Geodetic Datum 1984'; + case 6204: + return 'Ain el Abd 1970'; + case 6205: + return 'Afgooye'; + case 6206: + return 'Agadez'; + case 6207: + return 'Lisbon'; + case 6208: + return 'Aratu'; + case 6209: + return 'Arc 1950'; + case 6210: + return 'Arc 1960'; + case 6211: + return 'Batavia'; + case 6212: + return 'Barbados'; + case 6213: + return 'Beduaram'; + case 6214: + return 'Beijing 1954'; + case 6215: + return 'Reseau National Belge 1950'; + case 6216: + return 'Bermuda 1957'; + case 6217: + return 'Bern 1898'; + case 6218: + return 'Bogota'; + case 6219: + return 'Bukit Rimpah'; + case 6220: + return 'Camacupa'; + case 6221: + return 'Campo Inchauspe'; + case 6222: + return 'Cape'; + case 6223: + return 'Carthage'; + case 6224: + return 'Chua'; + case 6225: + return 'Corrego Alegre'; + case 6226: + return 'Cote d Ivoire'; + case 6227: + return 'Deir ez Zor'; + case 6228: + return 'Douala'; + case 6229: + return 'Egypt 1907'; + case 6230: + return 'European Datum 1950'; + case 6231: + return 'European Datum 1987'; + case 6232: + return 'Fahud'; + case 6233: + return 'Gandajika 1970'; + case 6234: + return 'Garoua'; + case 6235: + return 'Guyane Francaise'; + case 6236: + return 'Hu Tzu Shan'; + case 6237: + return 'Hungarian Datum 1972'; + case 6238: + return 'Indonesian Datum 1974'; + case 6239: + return 'Indian 1954'; + case 6240: + return 'Indian 1975'; + case 6241: + return 'Jamaica 1875'; + case 6242: + return 'Jamaica 1969'; + case 6243: + return 'Kalianpur'; + case 6244: + return 'Kandawala'; + case 6245: + return 'Kertau'; + case 6246: + return 'Kuwait Oil Company'; + case 6247: + return 'La Canoa'; + case 6248: + return 'Provisional S American Datum 1956'; + case 6249: + return 'Lake'; + case 6250: + return 'Leigon'; + case 6251: + return 'Liberia 1964'; + case 6252: + return 'Lome'; + case 6253: + return 'Luzon 1911'; + case 6254: + return 'Hito XVIII 1963'; + case 6255: + return 'Herat North'; + case 6256: + return 'Mahe 1971'; + case 6257: + return 'Makassar'; + case 6258: + return 'European Reference System 1989'; + case 6259: + return 'Malongo 1987'; + case 6260: + return 'Manoca'; + case 6261: + return 'Merchich'; + case 6262: + return 'Massawa'; + case 6263: + return 'Minna'; + case 6264: + return 'Mhast'; + case 6265: + return 'Monte Mario'; + case 6266: + return 'M poraloko'; + case 6267: + return 'North American Datum 1927'; + case 6268: + return 'NAD Michigan'; + case 6269: + return 'North American Datum 1983'; + case 6270: + return 'Nahrwan 1967'; + case 6271: + return 'Naparima 1972'; + case 6272: + return 'New Zealand Geodetic Datum 1949'; + case 6273: + return 'NGO 1948'; + case 6274: + return 'Datum 73'; + case 6275: + return 'Nouvelle Triangulation Francaise'; + case 6276: + return 'NSWC 9Z 2'; + case 6277: + return 'OSGB 1936'; + case 6278: + return 'OSGB 1970 SN'; + case 6279: + return 'OS SN 1980'; + case 6280: + return 'Padang 1884'; + case 6281: + return 'Palestine 1923'; + case 6282: + return 'Pointe Noire'; + case 6283: + return 'Geocentric Datum of Australia 1994'; + case 6284: + return 'Pulkovo 1942'; + case 6285: + return 'Qatar'; + case 6286: + return 'Qatar 1948'; + case 6287: + return 'Qornoq'; + case 6288: + return 'Loma Quintana'; + case 6289: + return 'Amersfoort'; + case 6290: + return 'RT38'; + case 6291: + return 'South American Datum 1969'; + case 6292: + return 'Sapper Hill 1943'; + case 6293: + return 'Schwarzeck'; + case 6294: + return 'Segora'; + case 6295: + return 'Serindung'; + case 6296: + return 'Sudan'; + case 6297: + return 'Tananarive 1925'; + case 6298: + return 'Timbalai 1948'; + case 6299: + return 'TM65'; + case 6300: + return 'TM75'; + case 6301: + return 'Tokyo'; + case 6302: + return 'Trinidad 1903'; + case 6303: + return 'Trucial Coast 1948'; + case 6304: + return 'Voirol 1875'; + case 6305: + return 'Voirol Unifie 1960'; + case 6306: + return 'Bern 1938'; + case 6307: + return 'Nord Sahara 1959'; + case 6308: + return 'Stockholm 1938'; + case 6309: + return 'Yacare'; + case 6310: + return 'Yoff'; + case 6311: + return 'Zanderij'; + case 6312: + return 'Militar Geographische Institut'; + case 6313: + return 'Reseau National Belge 1972'; + case 6314: + return 'Deutsche Hauptdreiecksnetz'; + case 6315: + return 'Conakry 1905'; + case 6317: + return 'Dealul Piscului 1970'; + case 6322: + return 'WGS72'; + case 6324: + return 'WGS72 Transit Broadcast Ephemeris'; + case 6326: + return 'WGS84'; + case 6901: + return 'Ancienne Triangulation Francaise'; + case 6902: + return 'Nord de Guerre'; + case 32767: + return 'User Defined'; + default: + return 'Unknown ($value)'; + } + } + + static String getGeogEllipsoidDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 7001: + return 'Airy 1830'; + case 7002: + return 'Airy Modified 1849'; + case 7003: + return 'Australian National Spheroid'; + case 7004: + return 'Bessel 1841'; + case 7005: + return 'Bessel Modified'; + case 7006: + return 'Bessel Namibia'; + case 7007: + return 'Clarke 1858'; + case 7008: + return 'Clarke 1866'; + case 7009: + return 'Clarke 1866 Michigan'; + case 7010: + return 'Clarke 1880 Benoit'; + case 7011: + return 'Clarke 1880 IGN'; + case 7012: + return 'Clarke 1880 RGS'; + case 7013: + return 'Clarke 1880 Arc'; + case 7014: + return 'Clarke 1880 SGA 1922'; + case 7015: + return 'Everest 1830 1937 Adjustment'; + case 7016: + return 'Everest 1830 1967 Definition'; + case 7017: + return 'Everest 1830 1975 Definition'; + case 7018: + return 'Everest 1830 Modified'; + case 7019: + return 'GRS 1980'; + case 7020: + return 'Helmert 1906'; + case 7021: + return 'Indonesian National Spheroid'; + case 7022: + return 'International 1924'; + case 7023: + return 'International 1967'; + case 7024: + return 'Krassowsky 1940'; + case 7025: + return 'NWL 9D'; + case 7026: + return 'NWL 10D'; + case 7027: + return 'Plessis 1817'; + case 7028: + return 'Struve 1860'; + case 7029: + return 'War Office'; + case 7030: + return 'WGS 84'; + case 7031: + return 'GEM 10C'; + case 7032: + return 'OSU86F'; + case 7033: + return 'OSU91A'; + case 7034: + return 'Clarke 1880'; + case 7035: + return 'Sphere'; + case 32767: + return 'User Defined'; + default: + return 'Unknown ($value)'; + } + } + static String getProjCSTypeDescription(String valueString) { final value = int.tryParse(valueString); if (value == null) return valueString; @@ -469,6 +885,8 @@ class GeoTiffDirectory { return 'RT90 2 5 gon W'; case 2600: return 'Lietuvos Koordinoei Sistema 1994'; + case 3045: + return 'ETRS89 UTM zone 33N'; case 3053: return 'Hjorsey 1955 Lambert'; case 3057: diff --git a/pubspec.lock b/pubspec.lock index d35dcf49c..5d58ce3c9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -841,7 +841,7 @@ packages: source: hosted version: "4.2.4" proj4dart: - dependency: transitive + dependency: "direct main" description: name: proj4dart url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index c482b89aa..952ee5343 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: percent_indicator: permission_handler: printing: + proj4dart: provider: screen_brightness: shared_preferences: diff --git a/untranslated.json b/untranslated.json index 5806d5cb7..7540052a7 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,34 @@ { + "de": [ + "entryActionShowGeoTiffOnMap" + ], + + "es": [ + "entryActionShowGeoTiffOnMap" + ], + + "fr": [ + "entryActionShowGeoTiffOnMap" + ], + + "id": [ + "entryActionShowGeoTiffOnMap" + ], + + "ja": [ + "entryActionShowGeoTiffOnMap" + ], + + "ko": [ + "entryActionShowGeoTiffOnMap" + ], + + "pt": [ + "entryActionShowGeoTiffOnMap" + ], + "ru": [ + "entryActionShowGeoTiffOnMap", "displayRefreshRatePreferHighest", "displayRefreshRatePreferLowest", "themeBrightnessLight",