geotiff: locating, map overlay

This commit is contained in:
Thibault Deckers 2022-04-07 11:28:03 +09:00
parent 1f99117f12
commit f34dca8019
24 changed files with 1138 additions and 81 deletions

View file

@ -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 https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFFphotoshop.pdf
*/ */
object ExifTags { object ExifTags {
private const val TAG_X_POSITION = 0x011e private const val X_POSITION = 0x011e
private const val TAG_Y_POSITION = 0x011f private const val Y_POSITION = 0x011f
private const val TAG_T4_OPTIONS = 0x0124 private const val T4_OPTIONS = 0x0124
private const val TAG_T6_OPTIONS = 0x0125 private const val T6_OPTIONS = 0x0125
private const val TAG_COLOR_MAP = 0x0140 private const val COLOR_MAP = 0x0140
private const val TAG_EXTRA_SAMPLES = 0x0152 private const val EXTRA_SAMPLES = 0x0152
private const val TAG_SAMPLE_FORMAT = 0x0153 private const val SAMPLE_FORMAT = 0x0153
private const val TAG_RATING_PERCENT = 0x4749 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_RAW_FILE_TYPE = 0x7000
private const val SONY_TONE_CURVE = 0x7010 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) // sensing method (0x9217) redundant with sensing method (0xA217)
private const val TAG_SENSING_METHOD = 0x9217 private const val SENSING_METHOD = 0x9217
private const val TAG_IMAGE_SOURCE_DATA = 0x935c private const val IMAGE_SOURCE_DATA = 0x935c
private const val TAG_GDAL_METADATA = 0xa480 private const val GDAL_METADATA = 0xa480
private const val TAG_GDAL_NO_DATA = 0xa481 private const val GDAL_NO_DATA = 0xa481
private val tagNameMap = hashMapOf( private val tagNameMap = hashMapOf(
TAG_X_POSITION to "X Position", X_POSITION to "X Position",
TAG_Y_POSITION to "Y Position", Y_POSITION to "Y Position",
TAG_T4_OPTIONS to "T4 Options", T4_OPTIONS to "T4 Options",
TAG_T6_OPTIONS to "T6 Options", T6_OPTIONS to "T6 Options",
TAG_COLOR_MAP to "Color Map", COLOR_MAP to "Color Map",
TAG_EXTRA_SAMPLES to "Extra Samples", EXTRA_SAMPLES to "Extra Samples",
TAG_SAMPLE_FORMAT to "Sample Format", SAMPLE_FORMAT to "Sample Format",
TAG_RATING_PERCENT to "Rating Percent", 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_RAW_FILE_TYPE to "Sony Raw File Type",
SONY_TONE_CURVE to "Sony Tone Curve", SONY_TONE_CURVE to "Sony Tone Curve",
TAG_MATTEING to "Matteing", MATTEING to "Matteing",
TAG_SENSING_METHOD to "Sensing Method (0x9217)", SENSING_METHOD to "Sensing Method (0x9217)",
TAG_IMAGE_SOURCE_DATA to "Image Source Data", IMAGE_SOURCE_DATA to "Image Source Data",
TAG_GDAL_METADATA to "GDAL Metadata", GDAL_METADATA to "GDAL Metadata",
TAG_GDAL_NO_DATA to "GDAL No Data", GDAL_NO_DATA to "GDAL No Data",
).apply { ).apply {
putAll(DngTags.tagNameMap) putAll(DngTags.tagNameMap)
putAll(ExifGeoTiffTags.tagNameMap) putAll(ExifGeoTiffTags.tagNameMap)

View file

@ -9,16 +9,27 @@ object GeoTiffKeys {
private const val CITATION = 0x0402 private const val CITATION = 0x0402
private const val GEOG_TYPE = 0x0800 private const val GEOG_TYPE = 0x0800
private const val GEOG_CITATION = 0x0801 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_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_TYPE = 0x0c00
private const val PROJ_CS_CITATION = 0x0c01 private const val PROJ_CS_CITATION = 0x0c01
private const val PROJECTION = 0x0c02 private const val PROJECTION = 0x0c02
private const val PROJ_COORD_TRANS = 0x0c03 private const val PROJ_COORD_TRANS = 0x0c03
private const val PROJ_LINEAR_UNITS = 0x0c04 private const val PROJ_LINEAR_UNITS = 0x0c04
private const val PROJ_STD_PARALLEL_1 = 0x0c06 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_LONG = 0x0c08
private const val PROJ_NAT_ORIGIN_LAT = 0x0c09
private const val PROJ_FALSE_EASTING = 0x0c0a private const val PROJ_FALSE_EASTING = 0x0c0a
private const val PROJ_FALSE_NORTHING = 0x0c0b 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( private val tagNameMap = hashMapOf(
GEOTIFF_VERSION to "GeoTIFF Version", GEOTIFF_VERSION to "GeoTIFF Version",
@ -27,16 +38,27 @@ object GeoTiffKeys {
CITATION to "Citation", CITATION to "Citation",
GEOG_TYPE to "Geographic Type", GEOG_TYPE to "Geographic Type",
GEOG_CITATION to "Geographic Citation", 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_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_TYPE to "Projected Coordinate System Type",
PROJ_CS_CITATION to "Projected Coordinate System Citation", PROJ_CS_CITATION to "Projected Coordinate System Citation",
PROJECTION to "Projection", PROJECTION to "Projection",
PROJ_COORD_TRANS to "Projected Coordinate Transform", PROJ_COORD_TRANS to "Projected Coordinate Transform",
PROJ_LINEAR_UNITS to "Projection Linear Units", PROJ_LINEAR_UNITS to "Projection Linear Units",
PROJ_STD_PARALLEL_1 to "Projection Standard Parallel 1", 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_LONG to "Projection Natural Origin Longitude",
PROJ_NAT_ORIGIN_LAT to "Projection Natural Origin Latitude",
PROJ_FALSE_EASTING to "Projection False Easting", PROJ_FALSE_EASTING to "Projection False Easting",
PROJ_FALSE_NORTHING to "Projection False Northing", 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? { fun getTagName(tag: Int): String? {

View file

@ -86,6 +86,7 @@
"entryActionPrint": "Print", "entryActionPrint": "Print",
"entryActionShare": "Share", "entryActionShare": "Share",
"entryActionViewSource": "View source", "entryActionViewSource": "View source",
"entryActionShowGeoTiffOnMap": "Show as map overlay",
"entryActionViewMotionPhotoVideo": "Open Motion Photo", "entryActionViewMotionPhotoVideo": "Open Motion Photo",
"entryActionEdit": "Edit", "entryActionEdit": "Edit",
"entryActionOpen": "Open with", "entryActionOpen": "Open with",

View file

@ -10,6 +10,8 @@ enum EntryInfoAction {
editRating, editRating,
editTags, editTags,
removeMetadata, removeMetadata,
// GeoTIFF
showGeoTiffOnMap,
// motion photo // motion photo
viewMotionPhotoVideo, viewMotionPhotoVideo,
// debug // debug
@ -23,6 +25,7 @@ class EntryInfoActions {
EntryInfoAction.editRating, EntryInfoAction.editRating,
EntryInfoAction.editTags, EntryInfoAction.editTags,
EntryInfoAction.removeMetadata, EntryInfoAction.removeMetadata,
EntryInfoAction.showGeoTiffOnMap,
EntryInfoAction.viewMotionPhotoVideo, EntryInfoAction.viewMotionPhotoVideo,
]; ];
} }
@ -41,6 +44,9 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return context.l10n.entryInfoActionEditTags; return context.l10n.entryInfoActionEditTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata; return context.l10n.entryInfoActionRemoveMetadata;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return context.l10n.entryActionShowGeoTiffOnMap;
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo; return context.l10n.entryActionViewMotionPhotoVideo;
@ -77,6 +83,9 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return AIcons.editTags; return AIcons.editTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return AIcons.clear; return AIcons.clear;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return AIcons.map;
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return AIcons.motionPhoto; return AIcons.motionPhoto;

View file

@ -5,6 +5,7 @@ import 'dart:ui';
import 'package:aves/geo/countries.dart'; import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.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/address.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/metadata/trash.dart';
@ -504,16 +505,35 @@ class AvesEntry {
} }
catalogMetadata = CatalogMetadata(id: id); catalogMetadata = CatalogMetadata(id: id);
} else { } else {
// pre-processing
if (isVideo && (!isSized || durationMillis == 0)) { if (isVideo && (!isSized || durationMillis == 0)) {
// exotic video that is not sized during loading // exotic video that is not sized during loading
final fields = await VideoMetadataFormatter.getLoadingMetadata(this); final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
await applyNewFields(fields, persist: persist); await applyNewFields(fields, persist: persist);
} }
// cataloguing on platform
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background); catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
// post-processing
if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) { if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) {
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this); 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,
);
}
}
}
} }
} }

231
lib/model/geotiff.dart Normal file
View file

@ -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<double>? modelPixelScale, modelTiePoints, modelTransformation;
final int? projCSType, projLinearUnits;
@override
List<Object?> 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<double>(),
modelTiePoints: (map[GeoTiffExifTags.modelTiePoints] as List?)?.cast<double>(),
modelTransformation: (map[GeoTiffExifTags.modelTransformation] as List?)?.cast<double>(),
projCSType: map[GeoTiffKeys.projCSType] as int?,
projLinearUnits: map[GeoTiffKeys.projLinearUnits] as int?,
);
}
}
class MappedGeoTiff {
final AvesEntry entry;
late LatLng? Function(Point<int> pixel) pointToLatLng;
late Point<int>? Function(Point<double> 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<MapTile?> 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<int>(1, highestPowerOf2(tileXScale));
final region = entry.getRegion(
sampleSize: sampleSize,
region: Rectangle(regionLeft, regionTop, regionWidth, regionHeight),
);
final imageInfoCompleter = Completer<ImageInfo?>();
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));
}

View file

@ -55,6 +55,8 @@ class CatalogMetadata {
int? dateMillis, int? dateMillis,
bool? isMultiPage, bool? isMultiPage,
int? rotationDegrees, int? rotationDegrees,
double? latitude,
double? longitude,
}) { }) {
return CatalogMetadata( return CatalogMetadata(
id: id ?? this.id, id: id ?? this.id,
@ -68,8 +70,8 @@ class CatalogMetadata {
rotationDegrees: rotationDegrees ?? this.rotationDegrees, rotationDegrees: rotationDegrees ?? this.rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
latitude: latitude, latitude: latitude ?? this.latitude,
longitude: longitude, longitude: longitude ?? this.longitude,
rating: rating, rating: rating,
); );
} }

View file

@ -11,9 +11,19 @@ class GeoTiffKeys {
static const int modelType = 0x0400; static const int modelType = 0x0400;
static const int rasterType = 0x0401; static const int rasterType = 0x0401;
static const int geographicType = 0x0800; static const int geographicType = 0x0800;
static const int geogGeodeticDatum = 0x0802;
static const int geogAngularUnits = 0x0806; static const int geogAngularUnits = 0x0806;
static const int geogEllipsoid = 0x0808;
static const int projCSType = 0x0c00; static const int projCSType = 0x0c00;
static const int projection = 0x0c02; static const int projection = 0x0c02;
static const int projCoordinateTransform = 0x0c03; static const int projCoordinateTransform = 0x0c03;
static const int projLinearUnits = 0x0c04; 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;
} }

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/geotiff.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/fields.dart'; import 'package:aves/model/metadata/fields.dart';
import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/metadata/overlay.dart';
@ -19,6 +20,8 @@ abstract class MetadataFetchService {
Future<OverlayMetadata?> getOverlayMetadata(AvesEntry entry); Future<OverlayMetadata?> getOverlayMetadata(AvesEntry entry);
Future<GeoTiffInfo?> getGeoTiffInfo(AvesEntry entry);
Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry); Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry);
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry); Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
@ -117,6 +120,23 @@ class PlatformMetadataFetchService implements MetadataFetchService {
return null; return null;
} }
@override
Future<GeoTiffInfo?> getGeoTiffInfo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getGeoTiffInfo', <String, dynamic>{
'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 @override
Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry) async { Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry) async {
try { try {

View file

@ -24,6 +24,7 @@ class AIcons {
static const IconData location = Icons.place_outlined; static const IconData location = Icons.place_outlined;
static const IconData locationUnlocated = Icons.location_off_outlined; static const IconData locationUnlocated = Icons.location_off_outlined;
static const IconData mainStorage = Icons.smartphone_outlined; static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData opacity = Icons.opacity;
static const IconData privacy = MdiIcons.shieldAccountOutline; static const IconData privacy = MdiIcons.shieldAccountOutline;
static const IconData rating = Icons.star_border_outlined; static const IconData rating = Icons.star_border_outlined;
static const IconData ratingFull = Icons.star; static const IconData ratingFull = Icons.star;

View file

@ -317,6 +317,11 @@ class Constants {
license: 'Apache 2.0', license: 'Apache 2.0',
sourceUrl: 'https://github.com/DavBfr/dart_pdf', sourceUrl: 'https://github.com/DavBfr/dart_pdf',
), ),
Dependency(
name: 'Proj4dart',
license: 'MIT',
sourceUrl: 'https://github.com/maRci002/proj4dart',
),
Dependency( Dependency(
name: 'Stack Trace', name: 'Stack Trace',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',

View file

@ -56,4 +56,98 @@ class GeoUtils {
final east = ne.longitude; final east = ne.longitude;
return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east)); 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<double> 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<double> tileTopLeft(int tx, int ty, int zoomLevel) {
final px = tx * tileSize;
final py = ty * tileSize;
return pixelsToMeters(px.toDouble(), py.toDouble(), zoomLevel);
}
} }

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart'; 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/enums.dart';
import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -33,6 +34,8 @@ class GeoMap extends StatefulWidget {
final LatLng? initialCenter; final LatLng? initialCenter;
final ValueNotifier<bool> isAnimatingNotifier; final ValueNotifier<bool> isAnimatingNotifier;
final ValueNotifier<LatLng?>? dotLocationNotifier; final ValueNotifier<LatLng?>? dotLocationNotifier;
final ValueNotifier<double>? overlayOpacityNotifier;
final MappedGeoTiff? overlayEntry;
final UserZoomChangeCallback? onUserZoomChange; final UserZoomChangeCallback? onUserZoomChange;
final void Function(LatLng location)? onMapTap; final void Function(LatLng location)? onMapTap;
final MarkerTapCallback? onMarkerTap; final MarkerTapCallback? onMarkerTap;
@ -49,6 +52,8 @@ class GeoMap extends StatefulWidget {
this.initialCenter, this.initialCenter,
required this.isAnimatingNotifier, required this.isAnimatingNotifier,
this.dotLocationNotifier, this.dotLocationNotifier,
this.overlayOpacityNotifier,
this.overlayEntry,
this.onUserZoomChange, this.onUserZoomChange,
this.onMapTap, this.onMapTap,
this.onMarkerTap, this.onMarkerTap,
@ -176,6 +181,8 @@ class _GeoMapState extends State<GeoMap> {
markerClusterBuilder: _buildMarkerClusters, markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget, markerWidgetBuilder: _buildMarkerWidget,
dotLocationNotifier: widget.dotLocationNotifier, dotLocationNotifier: widget.dotLocationNotifier,
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onUserZoomChange: widget.onUserZoomChange, onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap, onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap, onMarkerTap: _onMarkerTap,
@ -199,6 +206,8 @@ class _GeoMapState extends State<GeoMap> {
DotMarker.diameter + ImageMarker.outerBorderWidth * 2, DotMarker.diameter + ImageMarker.outerBorderWidth * 2,
DotMarker.diameter + ImageMarker.outerBorderWidth * 2, DotMarker.diameter + ImageMarker.outerBorderWidth * 2,
), ),
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onUserZoomChange: widget.onUserZoomChange, onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap, onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap, onMarkerTap: _onMarkerTap,
@ -262,12 +271,27 @@ class _GeoMapState extends State<GeoMap> {
} }
ZoomedBounds _initBounds() { ZoomedBounds _initBounds() {
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 initialCenter = widget.initialCenter;
final points = initialCenter != null ? {initialCenter} : entries.map((v) => v.latLng!).toSet(); final points = initialCenter != null ? {initialCenter} : entries.map((v) => v.latLng!).toSet();
final bounds = ZoomedBounds.fromPoints( bounds = ZoomedBounds.fromPoints(
points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]},
collocationZoom: settings.infoMapZoom, collocationZoom: settings.infoMapZoom,
); );
}
return bounds.copyWith( return bounds.copyWith(
zoom: max(bounds.zoom, minInitialZoom), zoom: max(bounds.zoom, minInitialZoom),
); );

View file

@ -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<Tile> 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;
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry_images.dart'; 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/enums/enums.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/map/buttons.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/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.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/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/google/marker_generator.dart';
import 'package:aves/widgets/common/map/marker.dart'; import 'package:aves/widgets/common/map/marker.dart';
import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/theme.dart';
@ -27,6 +29,8 @@ class EntryGoogleMap extends StatefulWidget {
final MarkerClusterBuilder markerClusterBuilder; final MarkerClusterBuilder markerClusterBuilder;
final MarkerWidgetBuilder markerWidgetBuilder; final MarkerWidgetBuilder markerWidgetBuilder;
final ValueNotifier<ll.LatLng?>? dotLocationNotifier; final ValueNotifier<ll.LatLng?>? dotLocationNotifier;
final ValueNotifier<double>? overlayOpacityNotifier;
final MappedGeoTiff? overlayEntry;
final UserZoomChangeCallback? onUserZoomChange; final UserZoomChangeCallback? onUserZoomChange;
final void Function(ll.LatLng location)? onMapTap; final void Function(ll.LatLng location)? onMapTap;
final void Function(GeoEntry geoEntry)? onMarkerTap; final void Function(GeoEntry geoEntry)? onMarkerTap;
@ -43,6 +47,8 @@ class EntryGoogleMap extends StatefulWidget {
required this.markerClusterBuilder, required this.markerClusterBuilder,
required this.markerWidgetBuilder, required this.markerWidgetBuilder,
required this.dotLocationNotifier, required this.dotLocationNotifier,
this.overlayOpacityNotifier,
this.overlayEntry,
this.onUserZoomChange, this.onUserZoomChange,
this.onMapTap, this.onMapTap,
this.onMarkerTap, this.onMarkerTap,
@ -169,9 +175,13 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
}); });
final interactive = context.select<MapThemeData, bool>((v) => v.interactive); final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
final overlayEntry = widget.overlayEntry;
return ValueListenableBuilder<ll.LatLng?>( return ValueListenableBuilder<ll.LatLng?>(
valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null), valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null),
builder: (context, dotLocation, child) { builder: (context, dotLocation, child) {
return ValueListenableBuilder<double>(
valueListenable: widget.overlayOpacityNotifier ?? ValueNotifier(1),
builder: (context, overlayOpacity, child) {
return GoogleMap( return GoogleMap(
initialCameraPosition: CameraPosition( initialCameraPosition: CameraPosition(
bearing: -bounds.rotation, bearing: -bounds.rotation,
@ -214,6 +224,15 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
zIndex: 1, 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), onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
onCameraIdle: _onIdle, onCameraIdle: _onIdle,
onTap: (position) => widget.onMapTap?.call(_fromGoogleLatLng(position)), onTap: (position) => widget.onMapTap?.call(_fromGoogleLatLng(position)),
@ -222,6 +241,8 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
); );
}, },
); );
},
);
} }
void _onIdle() { void _onIdle() {

View file

@ -1,5 +1,7 @@
import 'dart:async'; 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/enums/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
@ -30,6 +32,8 @@ class EntryLeafletMap extends StatefulWidget {
final MarkerWidgetBuilder markerWidgetBuilder; final MarkerWidgetBuilder markerWidgetBuilder;
final ValueNotifier<LatLng?>? dotLocationNotifier; final ValueNotifier<LatLng?>? dotLocationNotifier;
final Size markerSize, dotMarkerSize; final Size markerSize, dotMarkerSize;
final ValueNotifier<double>? overlayOpacityNotifier;
final MappedGeoTiff? overlayEntry;
final UserZoomChangeCallback? onUserZoomChange; final UserZoomChangeCallback? onUserZoomChange;
final void Function(LatLng location)? onMapTap; final void Function(LatLng location)? onMapTap;
final void Function(GeoEntry geoEntry)? onMarkerTap; final void Function(GeoEntry geoEntry)? onMarkerTap;
@ -48,6 +52,8 @@ class EntryLeafletMap extends StatefulWidget {
required this.dotLocationNotifier, required this.dotLocationNotifier,
required this.markerSize, required this.markerSize,
required this.dotMarkerSize, required this.dotMarkerSize,
this.overlayOpacityNotifier,
this.overlayEntry,
this.onUserZoomChange, this.onUserZoomChange,
this.onMapTap, this.onMapTap,
this.onMarkerTap, this.onMarkerTap,
@ -174,6 +180,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
], ],
children: [ children: [
_buildMapLayer(), _buildMapLayer(),
if (widget.overlayEntry != null) _buildOverlayImageLayer(),
MarkerLayerWidget( MarkerLayerWidget(
options: MarkerLayerOptions( options: MarkerLayerOptions(
markers: markers, markers: markers,
@ -214,6 +221,32 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> 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<double>(
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 _onBoundsChange() => _debouncer(_onIdle);
void _onIdle() { void _onIdle() {

View file

@ -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,
});
}

View file

@ -28,13 +28,13 @@ class ThumbnailEntryOverlay extends StatelessWidget {
const AnimatedImageIcon() const AnimatedImageIcon()
else ...[ else ...[
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(), if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isGeotiff) const GeoTiffIcon(),
if (entry.is360) const SphericalImageIcon(), if (entry.is360) const SphericalImageIcon(),
], ],
if (entry.isMultiPage) ...[ if (entry.isMultiPage) ...[
if (entry.isMotionPhoto && context.select<GridThemeData, bool>((t) => t.showMotionPhoto)) const MotionPhotoIcon(), if (entry.isMotionPhoto && context.select<GridThemeData, bool>((t) => t.showMotionPhoto)) const MotionPhotoIcon(),
if (!entry.isMotionPhoto) MultiPageIcon(entry: entry), if (!entry.isMotionPhoto) MultiPageIcon(entry: entry),
], ],
if (entry.isGeotiff) const GeoTiffIcon(),
if (entry.trashed && context.select<GridThemeData, bool>((t) => t.showTrash)) TrashIcon(trashDaysLeft: entry.trashDaysLeft), if (entry.trashed && context.select<GridThemeData, bool>((t) => t.showTrash)) TrashIcon(trashDaysLeft: entry.trashDaysLeft),
]; ];
if (children.isEmpty) return const SizedBox(); if (children.isEmpty) return const SizedBox();

View file

@ -4,12 +4,14 @@ import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/coordinate.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/geotiff.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart'; import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
@ -36,11 +38,13 @@ class MapPage extends StatelessWidget {
final CollectionLens collection; final CollectionLens collection;
final AvesEntry? initialEntry; final AvesEntry? initialEntry;
final MappedGeoTiff? overlayEntry;
const MapPage({ const MapPage({
Key? key, Key? key,
required this.collection, required this.collection,
this.initialEntry, this.initialEntry,
this.overlayEntry,
}) : super(key: key); }) : super(key: key);
@override @override
@ -59,6 +63,7 @@ class MapPage extends StatelessWidget {
child: _Content( child: _Content(
collection: collection, collection: collection,
initialEntry: initialEntry, initialEntry: initialEntry,
overlayEntry: overlayEntry,
), ),
), ),
), ),
@ -70,11 +75,13 @@ class MapPage extends StatelessWidget {
class _Content extends StatefulWidget { class _Content extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
final AvesEntry? initialEntry; final AvesEntry? initialEntry;
final MappedGeoTiff? overlayEntry;
const _Content({ const _Content({
Key? key, Key? key,
required this.collection, required this.collection,
this.initialEntry, this.initialEntry,
this.overlayEntry,
}) : super(key: key); }) : super(key: key);
@override @override
@ -89,6 +96,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null); final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null);
final ValueNotifier<LatLng?> _dotLocationNotifier = ValueNotifier(null); final ValueNotifier<LatLng?> _dotLocationNotifier = ValueNotifier(null);
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null); final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null);
final ValueNotifier<double> _overlayOpacityNotifier = ValueNotifier(1);
final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true); final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
late AnimationController _overlayAnimationController; late AnimationController _overlayAnimationController;
@ -205,6 +213,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
const Divider(height: 0), const Divider(height: 0),
_buildOverlayController(),
_buildScroller(), _buildScroller(),
], ],
), ),
@ -224,9 +233,11 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
controller: _mapController, controller: _mapController,
collectionListenable: openingCollection, collectionListenable: openingCollection,
entries: openingCollection.sortedEntries, entries: openingCollection.sortedEntries,
initialCenter: widget.initialEntry?.latLng, initialCenter: widget.initialEntry?.latLng ?? widget.overlayEntry?.center,
isAnimatingNotifier: _isPageAnimatingNotifier, isAnimatingNotifier: _isPageAnimatingNotifier,
dotLocationNotifier: _dotLocationNotifier, dotLocationNotifier: _dotLocationNotifier,
overlayOpacityNotifier: _overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onMapTap: (_) => _toggleOverlay(), onMapTap: (_) => _toggleOverlay(),
onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async { onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async {
final index = regionCollection?.sortedEntries.indexOf(markerEntry); 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<double>(
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() { Widget _buildScroller() {
return Stack( return Stack(
children: [ children: [

View file

@ -4,10 +4,13 @@ import 'package:aves/model/actions/entry_info_actions.dart';
import 'package:aves/model/actions/events.dart'; import 'package:aves/model/actions/events.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_metadata_edition.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/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/entry_editor.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.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/action/single_entry_editor.dart';
import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart';
@ -34,6 +37,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
case EntryInfoAction.editTags: case EntryInfoAction.editTags:
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return true; return true;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return entry.isGeotiff;
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return entry.isMotionPhoto; return entry.isMotionPhoto;
@ -56,6 +62,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
return entry.canEditTags; return entry.canEditTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return entry.canRemoveMetadata; return entry.canRemoveMetadata;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return true;
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return true; return true;
@ -84,6 +93,10 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
await _removeMetadata(context); await _removeMetadata(context);
break; break;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
await _showGeoTiffOnMap(context);
break;
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
@ -135,6 +148,36 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
await edit(context, () => entry.removeMetadata(types)); await edit(context, () => entry.removeMetadata(types));
} }
Future<void> _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) { void _goToDebug(BuildContext context) {
Navigator.push( Navigator.push(
context, context,

View file

@ -1,6 +1,7 @@
import 'package:aves/ref/geotiff.dart'; import 'package:aves/ref/geotiff.dart';
class GeoTiffDirectory { class GeoTiffDirectory {
// TODO TLAD [geotiff] avoid string-based match
static int? tagForName(String name) { static int? tagForName(String name) {
switch (name) { switch (name) {
case 'Model Type': case 'Model Type':
@ -9,8 +10,12 @@ class GeoTiffDirectory {
return GeoTiffKeys.rasterType; return GeoTiffKeys.rasterType;
case 'Geographic Type': case 'Geographic Type':
return GeoTiffKeys.geographicType; return GeoTiffKeys.geographicType;
case 'Geographic Geodetic Datum':
return GeoTiffKeys.geogGeodeticDatum;
case 'Geographic Angular Units': case 'Geographic Angular Units':
return GeoTiffKeys.geogAngularUnits; return GeoTiffKeys.geogAngularUnits;
case 'Geographic Ellipsoid':
return GeoTiffKeys.geogEllipsoid;
case 'Projected Coordinate System Type': case 'Projected Coordinate System Type':
return GeoTiffKeys.projCSType; return GeoTiffKeys.projCSType;
case 'Projection': case 'Projection':
@ -19,6 +24,8 @@ class GeoTiffDirectory {
return GeoTiffKeys.projCoordinateTransform; return GeoTiffKeys.projCoordinateTransform;
case 'Projection Linear Units': case 'Projection Linear Units':
return GeoTiffKeys.projLinearUnits; return GeoTiffKeys.projLinearUnits;
case 'Vertical Units':
return GeoTiffKeys.verticalUnits;
default: default:
return null; return null;
} }
@ -32,6 +39,10 @@ class GeoTiffDirectory {
return getRasterTypeDescription(v); return getRasterTypeDescription(v);
case GeoTiffKeys.geographicType: case GeoTiffKeys.geographicType:
return getGeographicTypeDescription(v); return getGeographicTypeDescription(v);
case GeoTiffKeys.geogGeodeticDatum:
return getGeogGeodeticDatumDescription(v);
case GeoTiffKeys.geogEllipsoid:
return getGeogEllipsoidDescription(v);
case GeoTiffKeys.projCSType: case GeoTiffKeys.projCSType:
return getProjCSTypeDescription(v); return getProjCSTypeDescription(v);
case GeoTiffKeys.projection: case GeoTiffKeys.projection:
@ -40,6 +51,7 @@ class GeoTiffDirectory {
return getProjCoordinateTransformDescription(v); return getProjCoordinateTransformDescription(v);
case GeoTiffKeys.projLinearUnits: case GeoTiffKeys.projLinearUnits:
case GeoTiffKeys.geogAngularUnits: case GeoTiffKeys.geogAngularUnits:
case GeoTiffKeys.verticalUnits:
return getGeoTiffUnitsDescription(v); return getGeoTiffUnitsDescription(v);
default: default:
return v; 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) { static String getProjCSTypeDescription(String valueString) {
final value = int.tryParse(valueString); final value = int.tryParse(valueString);
if (value == null) return valueString; if (value == null) return valueString;
@ -469,6 +885,8 @@ class GeoTiffDirectory {
return 'RT90 2 5 gon W'; return 'RT90 2 5 gon W';
case 2600: case 2600:
return 'Lietuvos Koordinoei Sistema 1994'; return 'Lietuvos Koordinoei Sistema 1994';
case 3045:
return 'ETRS89 UTM zone 33N';
case 3053: case 3053:
return 'Hjorsey 1955 Lambert'; return 'Hjorsey 1955 Lambert';
case 3057: case 3057:

View file

@ -841,7 +841,7 @@ packages:
source: hosted source: hosted
version: "4.2.4" version: "4.2.4"
proj4dart: proj4dart:
dependency: transitive dependency: "direct main"
description: description:
name: proj4dart name: proj4dart
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"

View file

@ -64,6 +64,7 @@ dependencies:
percent_indicator: percent_indicator:
permission_handler: permission_handler:
printing: printing:
proj4dart:
provider: provider:
screen_brightness: screen_brightness:
shared_preferences: shared_preferences:

View file

@ -1,5 +1,34 @@
{ {
"de": [
"entryActionShowGeoTiffOnMap"
],
"es": [
"entryActionShowGeoTiffOnMap"
],
"fr": [
"entryActionShowGeoTiffOnMap"
],
"id": [
"entryActionShowGeoTiffOnMap"
],
"ja": [
"entryActionShowGeoTiffOnMap"
],
"ko": [
"entryActionShowGeoTiffOnMap"
],
"pt": [
"entryActionShowGeoTiffOnMap"
],
"ru": [ "ru": [
"entryActionShowGeoTiffOnMap",
"displayRefreshRatePreferHighest", "displayRefreshRatePreferHighest",
"displayRefreshRatePreferLowest", "displayRefreshRatePreferLowest",
"themeBrightnessLight", "themeBrightnessLight",