diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..ee70f6c2d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto + +# Declare files that will always have CRLF line endings on checkout. +*.dart text eol=lf +*.yaml text eol=lf +*.md text eol=lf + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index ad366f6d9..640f0bc03 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import './pages/animated_map_controller.dart'; +import './pages/animated_map_controller.dart'; import './pages/circle.dart'; +import './pages/custom_crs.dart'; import './pages/esri.dart'; import './pages/home.dart'; import './pages/map_controller.dart'; @@ -47,7 +48,8 @@ class MyApp extends StatelessWidget { MovingMarkersPage.route: (context) => MovingMarkersPage(), CirclePage.route: (context) => CirclePage(), OverlayImagePage.route: (context) => OverlayImagePage(), - WMSLayerPage.route: (context) => WMSLayerPage() + WMSLayerPage.route: (context) => WMSLayerPage(), + CustomCrsPage.route: (context) => CustomCrsPage(), }, ); } diff --git a/example/lib/pages/custom_crs.dart b/example/lib/pages/custom_crs.dart new file mode 100644 index 000000000..bac7d962f --- /dev/null +++ b/example/lib/pages/custom_crs.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/plugin_api.dart'; +import 'package:latlong/latlong.dart'; +import 'package:proj4dart/proj4dart.dart' as proj4; + +import '../widgets/drawer.dart'; + +class CustomCrsPage extends StatefulWidget { + static const String route = 'custom_crs'; + + @override + _CustomCrsPageState createState() => _CustomCrsPageState(); +} + +class _CustomCrsPageState extends State { + Proj4Crs epsg3413CRS; + + double maxZoom; + + // Define start center + proj4.Point point = proj4.Point(x: 65.05166470332148, y: -19.171744826394896); + + String initText = 'Map centered to'; + + proj4.Projection epsg4326; + + proj4.Projection epsg3413; + + @override + void initState() { + super.initState(); + + // EPSG:4326 is a predefined projection ships with proj4dart + epsg4326 = proj4.Projection('EPSG:4326'); + + // EPSG:3413 is a user-defined projection from a valid Proj4 definition string + // From: http://epsg.io/3413, proj definition: http://epsg.io/3413.proj4 + epsg3413 = proj4.Projection.add('EPSG:3413', + '+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs'); + + // 9 example zoom level resolutions + final resolutions = [ + 32768, + 16384, + 8192, + 4096, + 2048, + 1024, + 512, + 256, + 128, + ]; + + final epsg3413Bounds = Bounds( + CustomPoint(-4511619.0, -4511336.0), + CustomPoint(4510883.0, 4510996.0), + ); + + maxZoom = (resolutions.length - 1).toDouble(); + + // Define CRS + epsg3413CRS = Proj4Crs.fromFactory( + // CRS code + code: 'EPSG:3413', + // your proj4 delegate + proj4Projection: epsg3413, + // Resolution factors (projection units per pixel, for example meters/pixel) + // for zoom levels; specify either scales or resolutions, not both + resolutions: resolutions, + // Bounds of the CRS, in projected coordinates + // (if not specified, the layer's which uses this CRS will be infinite) + bounds: epsg3413Bounds, + // Tile origin, in projected coordinates, if set, this overrides the transformation option + // Some goeserver changes origin based on zoom level + // and some are not at all (use explicit/implicit null or use [CustomPoint(0, 0)]) + // @see https://github.com/kartena/Proj4Leaflet/pull/171 + origins: [CustomPoint(0, 0)], + // Scale factors (pixels per projection unit, for example pixels/meter) for zoom levels; + // specify either scales or resolutions, not both + scales: null, + // The transformation to use when transforming projected coordinates into pixel coordinates + transformation: null, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Custom CRS')), + drawer: buildDrawer(context, CustomCrsPage.route), + body: Padding( + padding: EdgeInsets.all(8.0), + child: Column( + children: [ + Padding( + padding: EdgeInsets.only(top: 8.0, bottom: 2.0), + child: Text( + 'This map is in EPSG:3413', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + fontSize: 16.0, + ), + ), + ), + Padding( + padding: EdgeInsets.only(top: 8.0, bottom: 2.0), + child: Text( + '$initText (${point.x.toStringAsFixed(5)}, ${point.y.toStringAsFixed(5)}) in EPSG:4326.', + ), + ), + Padding( + padding: EdgeInsets.only(top: 2.0, bottom: 2.0), + child: Text( + 'Which is (${epsg4326.transform(epsg3413, point).x.toStringAsFixed(2)}, ${epsg4326.transform(epsg3413, point).y.toStringAsFixed(2)}) in EPSG:3413.', + ), + ), + Padding( + padding: EdgeInsets.only(top: 2.0, bottom: 8.0), + child: Text('Tap on map to get more coordinates!'), + ), + Flexible( + child: FlutterMap( + options: MapOptions( + // Set the default CRS + crs: epsg3413CRS, + center: LatLng(point.x, point.y), + zoom: 3.0, + // Set maxZoom usually scales.length - 1 OR resolutions.length - 1 + // but not greater + maxZoom: maxZoom, + onTap: (p) => setState(() { + initText = 'You clicked at'; + point = proj4.Point(x: p.latitude, y: p.longitude); + }), + ), + layers: [ + TileLayerOptions( + opacity: 1.0, + backgroundColor: Colors.white.withOpacity(0), + wmsOptions: WMSTileLayerOptions( + // Set the WMS layer's CRS + crs: epsg3413CRS, + transparent: true, + format: 'image/jpeg', + baseUrl: + 'https://www.gebco.net/data_and_products/gebco_web_services/north_polar_view_wms/mapserv?', + layers: ['gebco_north_polar_view'], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart index b464b78e0..8cac00f0a 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import '../pages/animated_map_controller.dart'; +import '../pages/animated_map_controller.dart'; import '../pages/circle.dart'; +import '../pages/custom_crs.dart'; import '../pages/esri.dart'; import '../pages/home.dart'; import '../pages/map_controller.dart'; @@ -41,6 +42,13 @@ Drawer buildDrawer(BuildContext context, String currentRoute) { Navigator.pushReplacementNamed(context, WMSLayerPage.route); }, ), + ListTile( + title: const Text('Custom CRS'), + selected: currentRoute == CustomCrsPage.route, + onTap: () { + Navigator.pushReplacementNamed(context, CustomCrsPage.route); + }, + ), ListTile( title: const Text('Add Pins'), selected: currentRoute == TapToAddPage.route, diff --git a/lib/src/geo/crs/crs.dart b/lib/src/geo/crs/crs.dart index 8c7c5e260..271d94b75 100644 --- a/lib/src/geo/crs/crs.dart +++ b/lib/src/geo/crs/crs.dart @@ -1,11 +1,11 @@ import 'dart:math' as math; import 'package:flutter/cupertino.dart'; -import 'package:tuple/tuple.dart'; -import 'package:latlong/latlong.dart'; import 'package:flutter_map/src/core/bounds.dart'; - import 'package:flutter_map/src/core/point.dart'; +import 'package:latlong/latlong.dart'; +import 'package:proj4dart/proj4dart.dart' as proj4; +import 'package:tuple/tuple.dart'; /// An abstract representation of a /// [Coordinate Reference System](https://docs.qgis.org/testing/en/docs/gentle_gis_introduction/coordinate_reference_systems.html). @@ -152,6 +152,200 @@ class Epsg4326 extends Earth { super(); } +/// Custom CRS +class Proj4Crs extends Crs { + @override + String code; + + @override + final Projection projection; + + @override + final Transformation transformation; + + @override + bool infinite; + + @override + Tuple2 get wrapLat => null; + + @override + Tuple2 get wrapLng => null; + + final List _transformations; + + final List _scales; + + Proj4Crs({ + @required this.code, + @required this.projection, + @required this.transformation, + @required this.infinite, + @required List transformations, + @required List scales, + }) : assert(null != code), + assert(null != projection), + assert(null != transformation || null != transformations), + assert(null != infinite), + assert(null != scales), + _transformations = transformations, + _scales = scales; + + factory Proj4Crs.fromFactory({ + @required String code, + @required proj4.Projection proj4Projection, + Transformation transformation, + List origins, + Bounds bounds, + List scales, + List resolutions, + }) { + final Projection projection = + Proj4Projection(proj4Projection: proj4Projection, bounds: bounds); + List transformations; + var infinite = null == bounds; + List finalScales; + + if (null != scales && scales.isNotEmpty) { + finalScales = scales; + } else if (null != resolutions && resolutions.isNotEmpty) { + finalScales = resolutions.map((r) => 1 / r).toList(growable: false); + } else { + throw Exception( + 'Please provide scales or resolutions to determine scales'); + } + + if (null == origins || origins.isEmpty) { + transformation ??= Transformation(1, 0, -1, 0); + } else { + if (origins.length == 1) { + var origin = origins[0]; + transformation = Transformation(1, -origin.x, -1, origin.y); + } else { + transformations = + origins.map((p) => Transformation(1, -p.x, -1, p.y)).toList(); + transformation = null; + } + } + + return Proj4Crs( + code: code, + projection: projection, + transformation: transformation, + infinite: infinite, + transformations: transformations, + scales: finalScales, + ); + } + + /// Converts a point on the sphere surface (with a certain zoom) in a + /// map point. + @override + CustomPoint latLngToPoint(LatLng latlng, double zoom) { + try { + var projectedPoint = projection.project(latlng); + var scale = this.scale(zoom); + var transformation = _getTransformationByZoom(zoom); + + return transformation.transform(projectedPoint, scale.toDouble()); + } catch (e) { + return CustomPoint(0.0, 0.0); + } + } + + /// Converts a map point to the sphere coordinate (at a certain zoom). + @override + LatLng pointToLatLng(CustomPoint point, double zoom) { + var scale = this.scale(zoom); + var transformation = _getTransformationByZoom(zoom); + + var untransformedPoint = + transformation.untransform(point, scale.toDouble()); + try { + return projection.unproject(untransformedPoint); + } catch (e) { + return null; + } + } + + /// Rescales the bounds to a given zoom value. + @override + Bounds getProjectedBounds(double zoom) { + if (infinite) return null; + + var b = projection.bounds; + var s = scale(zoom); + + var transformation = _getTransformationByZoom(zoom); + + var min = transformation.transform(b.min, s.toDouble()); + var max = transformation.transform(b.max, s.toDouble()); + return Bounds(min, max); + } + + /// Zoom to Scale function. + @override + num scale(double zoom) { + var iZoom = zoom.floor(); + if (zoom == iZoom) { + return _scales[iZoom]; + } else { + // Non-integer zoom, interpolate + var baseScale = _scales[iZoom]; + var nextScale = _scales[iZoom + 1]; + var scaleDiff = nextScale - baseScale; + var zDiff = (zoom - iZoom); + return baseScale + scaleDiff * zDiff; + } + } + + /// Scale to Zoom function. + @override + num zoom(double scale) { + // Find closest number in this._scales, down + var downScale = _closestElement(_scales, scale); + var downZoom = _scales.indexOf(downScale); + // Check if scale is downScale => return array index + if (scale == downScale) { + return downZoom; + } + if (downScale == null) { + return double.negativeInfinity; + } + // Interpolate + var nextZoom = downZoom + 1; + var nextScale = _scales[nextZoom]; + if (nextScale == null) { + return double.infinity; + } + var scaleDiff = nextScale - downScale; + return (scale - downScale) / scaleDiff + downZoom; + } + + /// Get the closest lowest element in an array + double _closestElement(List array, double element) { + var low; + for (var i = array.length - 1; i >= 0; i--) { + if (array[i] <= element && (null == low || low < array[i])) { + low = array[i]; + } + } + return low; + } + + /// returns Transformation object based on zoom + Transformation _getTransformationByZoom(double zoom) { + if (null == _transformations) { + return transformation; + } + + var iZoom = zoom.floor(); + var lastIdx = _transformations.length - 1; + + return _transformations[iZoom > lastIdx ? lastIdx : iZoom]; + } +} + abstract class Projection { const Projection(); @@ -237,6 +431,36 @@ class SphericalMercator extends Projection { } } +class Proj4Projection extends Projection { + final proj4.Projection epsg4326; + + final proj4.Projection proj4Projection; + + @override + final Bounds bounds; + + Proj4Projection({ + @required this.proj4Projection, + @required this.bounds, + }) : epsg4326 = proj4.Projection('EPSG:4326'); + + @override + CustomPoint project(LatLng latlng) { + var point = epsg4326.transform( + proj4Projection, proj4.Point(x: latlng.longitude, y: latlng.latitude)); + + return CustomPoint(point.x, point.y); + } + + @override + LatLng unproject(CustomPoint point) { + var point2 = proj4Projection.transform( + epsg4326, proj4.Point(x: point.x, y: point.y)); + + return LatLng(inclusiveLat(point2.y), inclusiveLng(point2.x)); + } +} + class Transformation { final num a; final num b; diff --git a/pubspec.yaml b/pubspec.yaml index 088051c02..85d82bdf0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: sqflite: ^1.1.5 path_provider: ^1.5.1 vector_math: ^2.0.0 + proj4dart: ^1.0.0 dev_dependencies: pedantic: ^1.8.0