diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index ace11ee65..fea6ae126 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -32,11 +32,13 @@ export 'package:flutter_map/src/layer/marker_layer.dart'; export 'package:flutter_map/src/layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer.dart'; -export 'package:flutter_map/src/layer/tile_builder/tile_builder.dart'; -export 'package:flutter_map/src/layer/tile_layer.dart'; -export 'package:flutter_map/src/layer/tile_provider/file_tile_provider_io.dart' - if (dart.library.html) 'package:flutter_map/src/layer/tile_provider/file_tile_provider_web.dart'; -export 'package:flutter_map/src/layer/tile_provider/tile_provider.dart'; +export 'package:flutter_map/src/layer/tile_layer/coords.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart' + if (dart.library.html) 'package:flutter_map/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_provider/tile_provider.dart'; export 'package:flutter_map/src/plugins/plugin.dart'; /// Renders a map composed of a list of layers powered by [LayerOptions]. @@ -145,8 +147,11 @@ abstract class MapController { double get rotation; Stream get mapEventStream; + StreamSink get mapEventSink; + set state(MapState state); + void dispose(); LatLng? pointToLatLng(CustomPoint point); diff --git a/lib/src/layer/tile_layer.dart b/lib/src/layer/tile_layer.dart deleted file mode 100644 index fc68d2389..000000000 --- a/lib/src/layer/tile_layer.dart +++ /dev/null @@ -1,1480 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:collection/collection.dart' show MapEquality; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/core/bounds.dart'; -import 'package:flutter_map/src/core/util.dart' as util; -import 'package:flutter_map/src/map/map.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:tuple/tuple.dart'; - -typedef TemplateFunction = String Function( - String str, Map data); - -enum EvictErrorTileStrategy { - // never evict error Tiles - none, - // evict error Tiles during _pruneTiles / _abortLoading calls - dispose, - // evict error Tiles which are not visible anymore but respect margin (see keepBuffer option) - // (Tile's zoom level not equals current _tileZoom or Tile is out of viewport) - notVisibleRespectMargin, - // evict error Tiles which are not visible anymore - // (Tile's zoom level not equals current _tileZoom or Tile is out of viewport) - notVisible, -} - -typedef ErrorTileCallBack = void Function(Tile tile, dynamic error); - -/// Describes the needed properties to create a tile-based layer. A tile is an -/// image bound to a specific geographical position. -class TileLayerOptions extends LayerOptions { - /// Defines the structure to create the URLs for the tiles. `{s}` means one of - /// the available subdomains (can be omitted) `{z}` zoom level `{x}` and `{y}` - /// — tile coordinates `{r}` can be used to add "@2x" to the URL to - /// load retina tiles (can be omitted) - /// - /// Example: - /// - /// https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png - /// - /// Is translated to this: - /// - /// https://a.tile.openstreetmap.org/12/2177/1259.png - final String? urlTemplate; - - /// If `true`, inverses Y axis numbering for tiles (turn this on for - /// [TMS](https://en.wikipedia.org/wiki/Tile_Map_Service) services). - final bool tms; - - /// If not `null`, then tiles will pull's WMS protocol requests - final WMSTileLayerOptions? wmsOptions; - - /// Size for the tile. - /// Default is 256 - final double tileSize; - - // The minimum zoom level down to which this layer will be - // displayed (inclusive). - final double minZoom; - - /// The maximum zoom level up to which this layer will be displayed - /// (inclusive). In most tile providers goes from 0 to 19. - final double maxZoom; - - /// Minimum zoom number the tile source has available. If it is specified, the - /// tiles on all zoom levels lower than minNativeZoom will be loaded from - /// minNativeZoom level and auto-scaled. - final double? minNativeZoom; - - /// Maximum zoom number the tile source has available. If it is specified, the - /// tiles on all zoom levels higher than maxNativeZoom will be loaded from - /// maxNativeZoom level and auto-scaled. - final double? maxNativeZoom; - - /// If set to true, the zoom number used in tile URLs will be reversed - /// (`maxZoom - zoom` instead of `zoom`) - final bool zoomReverse; - - /// The zoom number used in tile URLs will be offset with this value. - final double zoomOffset; - - /// List of subdomains for the URL. - /// - /// Example: - /// - /// Subdomains = {a,b,c} - /// - /// and the URL is as follows: - /// - /// https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png - /// - /// then: - /// - /// https://a.tile.openstreetmap.org/{z}/{x}/{y}.png - /// https://b.tile.openstreetmap.org/{z}/{x}/{y}.png - /// https://c.tile.openstreetmap.org/{z}/{x}/{y}.png - final List subdomains; - - /// Color shown behind the tiles. - final Color backgroundColor; - - /// Opacity of the rendered tile - final double opacity; - - /// Provider to load the tiles. The default is `NonCachingNetworkTileProvider()` which - /// doesn't cache tiles and won't retry the HTTP request. Use `NetworkTileProvider()` for - /// a provider which will retry requests. For the best caching implementations, see the - /// flutter_map readme. - /// - /// In order to use images from the asset folder set this option to - /// AssetTileProvider() Note that it requires the urlTemplate to target - /// assets, for example: - /// - /// ```dart - /// urlTemplate: "assets/map/anholt_osmbright/{z}/{x}/{y}.png", - /// ``` - /// - /// In order to use images from the filesystem set this option to - /// FileTileProvider() Note that it requires the urlTemplate to target the - /// file system, for example: - /// - /// ```dart - /// urlTemplate: "/storage/emulated/0/tiles/some_place/{z}/{x}/{y}.png", - /// ``` - /// - /// Furthermore you create your custom implementation by subclassing - /// TileProvider - /// - final TileProvider tileProvider; - - /// When panning the map, keep this many rows and columns of tiles before - /// unloading them. - final int keepBuffer; - - /// Placeholder to show until tile images are fetched by the provider. - final ImageProvider? placeholderImage; - - /// Tile image to show in place of the tile that failed to load. - final ImageProvider? errorImage; - - /// Static information that should replace placeholders in the [urlTemplate]. - /// Applying API keys is a good example on how to use this parameter. - /// - /// Example: - /// - /// ```dart - /// - /// TileLayerOptions( - /// urlTemplate: "https://api.tiles.mapbox.com/v4/" - /// "{id}/{z}/{x}/{y}{r}.png?access_token={accessToken}", - /// additionalOptions: { - /// 'accessToken': '', - /// 'id': 'mapbox.streets', - /// }, - /// ), - /// ``` - /// - final Map additionalOptions; - - /// Tiles will not update more than once every `updateInterval` (default 200 - /// milliseconds) when panning. It can be null (but it will calculating for - /// loading tiles every frame when panning / zooming, flutter is fast) This - /// can save some fps and even bandwidth (ie. when fast panning / animating - /// between long distances in short time) - final Duration? updateInterval; - - /// Tiles fade in duration in milliseconds (default 100). This can be null to - /// avoid fade in. - final Duration? tileFadeInDuration; - - /// Opacity start value when Tile starts fade in (0.0 - 1.0) Takes effect if - /// `tileFadeInDuration` is not null - final double tileFadeInStart; - - /// Opacity start value when an exists Tile starts fade in with different Url - /// (0.0 - 1.0) Takes effect when `tileFadeInDuration` is not null and if - /// `overrideTilesWhenUrlChanges` if true - final double tileFadeInStartWhenOverride; - - /// `false`: current Tiles will be first dropped and then reload via new url - /// (default) `true`: current Tiles will be visible until new ones aren't - /// loaded (new Tiles are loaded independently) @see - /// https://github.com/johnpryan/flutter_map/issues/583 - final bool overrideTilesWhenUrlChanges; - - /// If `true`, it will request four tiles of half the specified size and a - /// bigger zoom level in place of one to utilize the high resolution. - /// - /// If `true` then MapOptions's `maxZoom` should be `maxZoom - 1` since - /// retinaMode just simulates retina display by playing with `zoomOffset`. If - /// geoserver supports retina `@2` tiles then it it advised to use them - /// instead of simulating it (use {r} in the [urlTemplate]) - /// - /// It is advised to use retinaMode if display supports it, write code like - /// this: - /// - /// ```dart - /// TileLayerOptions( - /// retinaMode: true && MediaQuery.of(context).devicePixelRatio > 1.0, - /// ), - /// ``` - final bool retinaMode; - - /// This callback will be execute if some errors occur when fetching tiles. - final ErrorTileCallBack? errorTileCallback; - - final TemplateFunction templateFunction; - - /// Function which may Wrap Tile with custom Widget - /// There are predefined examples in 'tile_builder.dart' - final TileBuilder? tileBuilder; - - /// Function which may wrap Tiles Container with custom Widget - /// There are predefined examples in 'tile_builder.dart' - final TilesContainerBuilder? tilesContainerBuilder; - - // If a Tile was loaded with error and if strategy isn't `none` then TileProvider - // will be asked to evict Image based on current strategy - // (see #576 - even Error Images are cached in flutter) - final EvictErrorTileStrategy evictErrorTileStrategy; - - /// This option is useful when you have a transparent layer: rather than - /// keeping the old layer visible when zooming (resulting in both layers - /// being temporarily visible), the old layer is removed as quickly as - /// possible when this is set to `true` (default `false`). - /// - /// This option is likely to cause some flickering of the transparent layer, - /// most noticeable when using pinch-to-zoom. It's best used with maps that - /// have `interactive` set to `false`, and zoom using buttons that call - /// `MapController.move()`. - /// - /// When set to `true`, the `tileFadeIn*` options will be ignored. - final bool fastReplace; - - ///Attribution widget builder - final WidgetBuilder? attributionBuilder; - - ///aligment of the attribution text on the map widget - final Alignment attributionAlignment; - - /// Stream to notify the [TileLayer] that it needs resetting - Stream? reset; - - /// Only load tiles that are within these bounds - LatLngBounds? tileBounds; - - TileLayerOptions( - {this.attributionAlignment = Alignment.bottomRight, - this.attributionBuilder, - Key? key, - // TODO: make required - this.urlTemplate, - double tileSize = 256.0, - double minZoom = 0.0, - double maxZoom = 18.0, - this.minNativeZoom, - this.maxNativeZoom, - this.zoomReverse = false, - double zoomOffset = 0.0, - Map? additionalOptions, - this.subdomains = const [], - this.keepBuffer = 2, - this.backgroundColor = const Color(0xFFE0E0E0), - this.placeholderImage, - this.errorImage, - this.tileProvider = const NonCachingNetworkTileProvider(), - this.tms = false, - // ignore: avoid_init_to_null - this.wmsOptions = null, - this.opacity = 1.0, - // Tiles will not update more than once every `updateInterval` milliseconds - // (default 200) when panning. It can be 0 (but it will calculating for - // loading tiles every frame when panning / zooming, flutter is fast) This - // can save some fps and even bandwidth (ie. when fast panning / animating - // between long distances in short time) - // TODO: change to Duration - int updateInterval = 200, - // Tiles fade in duration in milliseconds (default 100). This can be set to - // 0 to avoid fade in - // TODO: change to Duration - int tileFadeInDuration = 100, - this.tileFadeInStart = 0.0, - this.tileFadeInStartWhenOverride = 0.0, - this.overrideTilesWhenUrlChanges = false, - this.retinaMode = false, - this.errorTileCallback, - Stream? rebuild, - this.templateFunction = util.template, - this.tileBuilder, - this.tilesContainerBuilder, - this.evictErrorTileStrategy = EvictErrorTileStrategy.none, - this.fastReplace = false, - this.reset, - this.tileBounds}) - : updateInterval = - updateInterval <= 0 ? null : Duration(milliseconds: updateInterval), - tileFadeInDuration = tileFadeInDuration <= 0 - ? null - : Duration(milliseconds: tileFadeInDuration), - assert(tileFadeInStart >= 0.0 && tileFadeInStart <= 1.0), - assert(tileFadeInStartWhenOverride >= 0.0 && - tileFadeInStartWhenOverride <= 1.0), - maxZoom = - wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse - ? maxZoom - 1.0 - : maxZoom, - minZoom = - wmsOptions == null && retinaMode && maxZoom > 0.0 && zoomReverse - ? math.max(minZoom + 1.0, 0) - : minZoom, - zoomOffset = wmsOptions == null && retinaMode && maxZoom > 0.0 - ? (zoomReverse ? zoomOffset - 1.0 : zoomOffset + 1.0) - : zoomOffset, - tileSize = wmsOptions == null && retinaMode && maxZoom > 0.0 - ? (tileSize / 2.0).floorToDouble() - : tileSize, - // copy additionalOptions Map if not null, so we can safely compare old - // and new Map inside didUpdateWidget with MapEquality. - additionalOptions = additionalOptions == null - ? const {} - : Map.from(additionalOptions), - super(key: key, rebuild: rebuild); -} - -class WMSTileLayerOptions { - final service = 'WMS'; - final request = 'GetMap'; - - /// url of WMS service. - /// Ex.: 'http://ows.mundialis.de/services/service?' - final String baseUrl; - - /// list of WMS layers to show - final List layers; - - /// list of WMS styles - final List styles; - - /// WMS image format (use 'image/png' for layers with transparency) - final String format; - - /// Version of the WMS service to use - final String version; - - /// tile transparency flag - final bool transparent; - - /// Encode boolean values as uppercase in request - final bool uppercaseBoolValue; - - // TODO find a way to implicit pass of current map [Crs] - final Crs crs; - - /// other request parameters - final Map otherParameters; - - late final String _encodedBaseUrl; - - late final double _versionNumber; - - WMSTileLayerOptions({ - required this.baseUrl, - this.layers = const [], - this.styles = const [], - this.format = 'image/png', - this.version = '1.1.1', - this.transparent = true, - this.uppercaseBoolValue = false, - this.crs = const Epsg3857(), - this.otherParameters = const {}, - }) { - _versionNumber = double.tryParse(version.split('.').take(2).join('.')) ?? 0; - _encodedBaseUrl = _buildEncodedBaseUrl(); - } - - String _buildEncodedBaseUrl() { - final projectionKey = _versionNumber >= 1.3 ? 'crs' : 'srs'; - final buffer = StringBuffer(baseUrl) - ..write('&service=$service') - ..write('&request=$request') - ..write('&layers=${layers.map(Uri.encodeComponent).join(',')}') - ..write('&styles=${styles.map(Uri.encodeComponent).join(',')}') - ..write('&format=${Uri.encodeComponent(format)}') - ..write('&$projectionKey=${Uri.encodeComponent(crs.code)}') - ..write('&version=${Uri.encodeComponent(version)}') - ..write( - '&transparent=${uppercaseBoolValue ? transparent.toString().toUpperCase() : transparent}'); - otherParameters - .forEach((k, v) => buffer.write('&$k=${Uri.encodeComponent(v)}')); - return buffer.toString(); - } - - String getUrl(Coords coords, int tileSize, bool retinaMode) { - final tileSizePoint = CustomPoint(tileSize, tileSize); - final nvPoint = coords.scaleBy(tileSizePoint); - final sePoint = nvPoint + tileSizePoint; - final nvCoords = crs.pointToLatLng(nvPoint, coords.z as double)!; - final seCoords = crs.pointToLatLng(sePoint, coords.z as double)!; - final nv = crs.projection.project(nvCoords); - final se = crs.projection.project(seCoords); - final bounds = Bounds(nv, se); - final bbox = (_versionNumber >= 1.3 && crs is Epsg4326) - ? [bounds.min.y, bounds.min.x, bounds.max.y, bounds.max.x] - : [bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y]; - - final buffer = StringBuffer(_encodedBaseUrl); - buffer.write('&width=${retinaMode ? tileSize * 2 : tileSize}'); - buffer.write('&height=${retinaMode ? tileSize * 2 : tileSize}'); - buffer.write('&bbox=${bbox.join(',')}'); - return buffer.toString(); - } -} - -class TileLayerWidget extends StatelessWidget { - final TileLayerOptions options; - - const TileLayerWidget({Key? key, required this.options}) : super(key: key); - - @override - Widget build(BuildContext context) { - final mapState = MapState.maybeOf(context)!; - - return TileLayer( - mapState: mapState, - stream: mapState.onMoved, - options: options, - ); - } -} - -class TileLayer extends StatefulWidget { - final TileLayerOptions options; - final MapState mapState; - final Stream stream; - - TileLayer({ - required this.options, - required this.mapState, - required this.stream, - }) : super(key: options.key); - - @override - State createState() { - return _TileLayerState(); - } -} - -class _TileLayerState extends State with TickerProviderStateMixin { - MapState get map => widget.mapState; - - TileLayerOptions get options => widget.options; - late Bounds _globalTileRange; - Tuple2? _wrapX; - Tuple2? _wrapY; - double? _tileZoom; - - //ignore: unused_field - Level? _level; - StreamSubscription? _moveSub; - StreamSubscription? _resetSub; - StreamController? _throttleUpdate; - late CustomPoint _tileSize; - - final Map _tiles = {}; - final Map _levels = {}; - - Timer? _pruneLater; - - @override - void initState() { - super.initState(); - _tileSize = CustomPoint(options.tileSize, options.tileSize); - _resetView(); - _update(null); - _moveSub = widget.stream.listen((_) => _handleMove()); - - if (options.reset != null) { - _resetSub = options.reset?.listen((_) => _resetTiles()); - } - - _initThrottleUpdate(); - } - - @override - void didUpdateWidget(TileLayer oldWidget) { - super.didUpdateWidget(oldWidget); - var reloadTiles = false; - - if (oldWidget.options.tileSize != options.tileSize) { - _tileSize = CustomPoint(options.tileSize, options.tileSize); - reloadTiles = true; - } - - if (oldWidget.options.retinaMode != options.retinaMode) { - reloadTiles = true; - } - - reloadTiles |= _isZoomOutsideMinMax(); - - if (oldWidget.options.updateInterval != options.updateInterval) { - _throttleUpdate?.close(); - _initThrottleUpdate(); - } - - if (!reloadTiles) { - final oldUrl = oldWidget.options.wmsOptions?._encodedBaseUrl ?? - oldWidget.options.urlTemplate; - final newUrl = options.wmsOptions?._encodedBaseUrl ?? options.urlTemplate; - - final oldOptions = oldWidget.options.additionalOptions; - final newOptions = options.additionalOptions; - - if (oldUrl != newUrl || - !(const MapEquality()) - .equals(oldOptions, newOptions)) { - if (options.overrideTilesWhenUrlChanges) { - for (final tile in _tiles.values) { - tile.imageProvider = options.tileProvider - .getImage(_wrapCoords(tile.coords), options); - tile.loadTileImage(); - } - } else { - reloadTiles = true; - } - } - } - - if (reloadTiles) { - _removeAllTiles(); - _resetView(); - _update(null); - } - } - - bool _isZoomOutsideMinMax() { - for (final tile in _tiles.values) { - if (tile.level.zoom > (options.maxZoom) || - tile.level.zoom < (options.minZoom)) { - return true; - } - } - return false; - } - - void _initThrottleUpdate() { - if (options.updateInterval == null) { - _throttleUpdate = null; - } else { - _throttleUpdate = StreamController(sync: true); - _throttleUpdate!.stream - .transform( - util.throttleStreamTransformerWithTrailingCall( - options.updateInterval!, - ), - ) - .listen(_update); - } - } - - @override - void dispose() { - _removeAllTiles(); - _resetSub?.cancel(); - _moveSub?.cancel(); - _pruneLater?.cancel(); - options.tileProvider.dispose(); - _throttleUpdate?.close(); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final tilesToRender = _tiles.values.toList()..sort(); - - final tileWidgets = [ - for (var tile in tilesToRender) _createTileWidget(tile) - ]; - - final tilesContainer = Stack( - children: tileWidgets, - ); - - final tilesLayer = options.tilesContainerBuilder == null - ? tilesContainer - : options.tilesContainerBuilder!( - context, - tilesContainer, - tilesToRender, - ); - - final attributionLayer = widget.options.attributionBuilder?.call(context); - - return Opacity( - opacity: options.opacity, - child: Container( - color: options.backgroundColor, - child: Stack( - alignment: widget.options.attributionAlignment, - children: [ - tilesLayer, - if (attributionLayer != null) attributionLayer, - ], - ), - ), - ); - } - - Widget _createTileWidget(Tile tile) { - final tilePos = tile.tilePos; - final level = tile.level; - final tileSize = getTileSize(); - final pos = (tilePos).multiplyBy(level.scale) + level.translatePoint; - final num width = tileSize.x * level.scale; - final num height = tileSize.y * level.scale; - - final Widget content = AnimatedTile( - tile: tile, - errorImage: options.errorImage, - tileBuilder: options.tileBuilder, - ); - - return Positioned( - key: ValueKey(tile.coordsKey), - left: pos.x.toDouble(), - top: pos.y.toDouble(), - width: width.toDouble(), - height: height.toDouble(), - child: content, - ); - } - - void _abortLoading() { - final toRemove = []; - for (final entry in _tiles.entries) { - final tile = entry.value; - - if (tile.coords.z != _tileZoom) { - if (tile.loaded == null) { - toRemove.add(entry.key); - } - } - } - - for (final key in toRemove) { - final tile = _tiles[key]!; - - tile.tileReady = null; - tile.dispose(tile.loadError && - options.evictErrorTileStrategy != EvictErrorTileStrategy.none); - _tiles.remove(key); - } - } - - CustomPoint getTileSize() { - return _tileSize; - } - - bool _hasLevelChildren(double lvl) { - for (final tile in _tiles.values) { - if (tile.coords.z == lvl) { - return true; - } - } - - return false; - } - - Level? _updateLevels() { - final zoom = _tileZoom; - final maxZoom = options.maxZoom; - - if (zoom == null) return null; - - final toRemove = []; - for (final entry in _levels.entries) { - final z = entry.key; - final lvl = entry.value; - - if (z == zoom || _hasLevelChildren(z)) { - lvl.zIndex = maxZoom - (zoom - z).abs(); - } else { - toRemove.add(z); - } - } - - for (final z in toRemove) { - _removeTilesAtZoom(z); - _levels.remove(z); - } - - var level = _levels[zoom]; - final map = this.map; - - if (level == null) { - level = _levels[zoom] = Level(); - level.zIndex = maxZoom; - level.origin = map.project(map.unproject(map.getPixelOrigin()), zoom); - level.zoom = zoom; - - _setZoomTransform(level, map.center, map.zoom); - } - - return _level = level; - } - - void _pruneTiles() { - final zoom = _tileZoom; - if (zoom == null) { - _removeAllTiles(); - return; - } - - for (final entry in _tiles.entries) { - final tile = entry.value; - tile.retain = tile.current; - } - - for (final entry in _tiles.entries) { - final tile = entry.value; - - if (tile.current && !tile.active) { - final coords = tile.coords; - if (!_retainParent(coords.x, coords.y, coords.z, coords.z - 5)) { - _retainChildren(coords.x, coords.y, coords.z, coords.z + 2); - } - } - } - - final toRemove = []; - for (final entry in _tiles.entries) { - final tile = entry.value; - - if (!tile.retain) { - toRemove.add(entry.key); - } - } - - for (final key in toRemove) { - _removeTile(key); - } - } - - void _removeTilesAtZoom(double zoom) { - final toRemove = []; - for (final entry in _tiles.entries) { - if (entry.value.coords.z != zoom) { - continue; - } - toRemove.add(entry.key); - } - - for (final key in toRemove) { - _removeTile(key); - } - } - - ///removes all loaded tiles and resets the view - void _resetTiles() { - _removeAllTiles(); - _resetView(); - } - - void _removeAllTiles() { - final toRemove = Map.from(_tiles); - - for (final key in toRemove.keys) { - _removeTile(key); - } - } - - bool _retainParent(double x, double y, double z, double minZoom) { - final x2 = (x / 2).floorToDouble(); - final y2 = (y / 2).floorToDouble(); - final z2 = z - 1; - final coords2 = Coords(x2, y2); - coords2.z = z2; - - final key = _tileCoordsToKey(coords2); - - final tile = _tiles[key]; - if (tile != null) { - if (tile.active) { - tile.retain = true; - return true; - } else if (tile.loaded != null) { - tile.retain = true; - } - } - - if (z2 > minZoom) { - return _retainParent(x2, y2, z2, minZoom); - } - - return false; - } - - void _retainChildren(double x, double y, double z, double maxZoom) { - for (var i = 2 * x; i < 2 * x + 2; i++) { - for (var j = 2 * y; j < 2 * y + 2; j++) { - final coords = Coords(i, j); - coords.z = z + 1; - - final key = _tileCoordsToKey(coords); - - final tile = _tiles[key]; - if (tile != null) { - if (tile.active) { - tile.retain = true; - continue; - } else if (tile.loaded != null) { - tile.retain = true; - } - } - - if (z + 1 < maxZoom) { - _retainChildren(i, j, z + 1, maxZoom); - } - } - } - } - - void _resetView() { - _setView(map.center, map.zoom); - } - - double _clampZoom(double zoom) { - if (null != options.minNativeZoom && zoom < options.minNativeZoom!) { - return options.minNativeZoom!; - } - - if (null != options.maxNativeZoom && options.maxNativeZoom! < zoom) { - return options.maxNativeZoom!; - } - - return zoom; - } - - void _setView(LatLng center, double zoom) { - double? tileZoom = _clampZoom(zoom.roundToDouble()); - if ((tileZoom > options.maxZoom) || (tileZoom < options.minZoom)) { - tileZoom = null; - } - - _tileZoom = tileZoom; - - _abortLoading(); - - _updateLevels(); - _resetGrid(); - - if (_tileZoom != null) { - _update(center); - } - - _pruneTiles(); - } - - void _setZoomTransforms(LatLng center, double zoom) { - for (final i in _levels.keys) { - _setZoomTransform(_levels[i]!, center, zoom); - } - } - - void _setZoomTransform(Level level, LatLng center, double zoom) { - final scale = map.getZoomScale(zoom, level.zoom); - final pixelOrigin = map.getNewPixelOrigin(center, zoom).round(); - if (level.origin == null) { - return; - } - final translate = level.origin!.multiplyBy(scale) - pixelOrigin; - level.translatePoint = translate; - level.scale = scale; - } - - void _resetGrid() { - final map = this.map; - final crs = map.options.crs; - final tileSize = getTileSize(); - final tileZoom = _tileZoom; - - final bounds = map.getPixelWorldBounds(_tileZoom); - if (bounds != null) { - _globalTileRange = _pxBoundsToTileRange(bounds); - } - - // wrapping - _wrapX = crs.wrapLng; - if (_wrapX != null) { - final first = - (map.project(LatLng(0, crs.wrapLng!.item1), tileZoom).x / tileSize.x) - .floorToDouble(); - final second = - (map.project(LatLng(0, crs.wrapLng!.item2), tileZoom).x / tileSize.y) - .ceilToDouble(); - _wrapX = Tuple2(first, second); - } - - _wrapY = crs.wrapLat; - if (_wrapY != null) { - final first = - (map.project(LatLng(crs.wrapLat!.item1, 0), tileZoom).y / tileSize.x) - .floorToDouble(); - final second = - (map.project(LatLng(crs.wrapLat!.item2, 0), tileZoom).y / tileSize.y) - .ceilToDouble(); - _wrapY = Tuple2(first, second); - } - } - - void _handleMove() { - final tileZoom = _clampZoom(map.zoom.roundToDouble()); - - if (_tileZoom == null) { - // if there is no _tileZoom available it means we are out within zoom level - // we will restore fully via _setView call if we are back on trail - if ((tileZoom <= options.maxZoom) && (tileZoom >= options.minZoom)) { - _tileZoom = tileZoom; - setState(() { - _setView(map.center, tileZoom); - - _setZoomTransforms(map.center, map.zoom); - }); - } - } else { - setState(() { - if ((tileZoom - _tileZoom!).abs() >= 1) { - // It was a zoom lvl change - _setView(map.center, tileZoom); - - _setZoomTransforms(map.center, map.zoom); - } else { - if (_throttleUpdate == null) { - _update(null); - } else { - _throttleUpdate!.add(null); - } - - _setZoomTransforms(map.center, map.zoom); - } - }); - } - } - - Bounds _getTiledPixelBounds(LatLng center) { - final scale = map.getZoomScale(map.zoom, _tileZoom); - final pixelCenter = map.project(center, _tileZoom).floor(); - final halfSize = map.size / (scale * 2); - - return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); - } - - // Private method to load tiles in the grid's active zoom level according to - // map bounds - void _update(LatLng? center) { - if (_tileZoom == null) { - return; - } - - final zoom = _clampZoom(map.zoom); - center ??= map.center; - - final pixelBounds = _getTiledPixelBounds(center); - final tileRange = _pxBoundsToTileRange(pixelBounds); - final tileCenter = tileRange.center; - final queue = >[]; - final margin = options.keepBuffer; - final noPruneRange = Bounds( - tileRange.bottomLeft - CustomPoint(margin, -margin), - tileRange.topRight + CustomPoint(margin, -margin), - ); - - for (final entry in _tiles.entries) { - final tile = entry.value; - final c = tile.coords; - - if (tile.current == true && - (c.z != _tileZoom || !noPruneRange.contains(CustomPoint(c.x, c.y)))) { - tile.current = false; - } - } - - // _update just loads more tiles. If the tile zoom level differs too much - // from the map's, let _setView reset levels and prune old tiles. - if ((zoom - _tileZoom!).abs() > 1) { - _setView(center, zoom); - return; - } - - // create a queue of coordinates to load tiles from - for (var j = tileRange.min.y; j <= tileRange.max.y; j++) { - for (var i = tileRange.min.x; i <= tileRange.max.x; i++) { - final coords = Coords(i.toDouble(), j.toDouble()); - coords.z = _tileZoom!; - - if (options.tileBounds != null) { - final tilePxBounds = _pxBoundsToTileRange( - _latLngBoundsToPixelBounds(options.tileBounds!, _tileZoom!)); - if (!_areCoordsInsideTileBounds(coords, tilePxBounds)) { - continue; - } - } - - if (!_isValidTile(coords)) { - continue; - } - - final tile = _tiles[_tileCoordsToKey(coords)]; - if (tile != null) { - tile.current = true; - } else { - queue.add(coords); - } - } - } - - _evictErrorTilesBasedOnStrategy(tileRange); - - // sort tile queue to load tiles in order of their distance to center - queue.sort((a, b) => - (a.distanceTo(tileCenter) - b.distanceTo(tileCenter)).toInt()); - - for (var i = 0; i < queue.length; i++) { - _addTile(queue[i] as Coords); - } - } - - bool _isValidTile(Coords coords) { - final crs = map.options.crs; - - if (!crs.infinite) { - // don't load tile if it's out of bounds and not wrapped - final bounds = _globalTileRange; - if ((crs.wrapLng == null && - (coords.x < bounds.min.x || coords.x > bounds.max.x)) || - (crs.wrapLat == null && - (coords.y < bounds.min.y || coords.y > bounds.max.y))) { - return false; - } - } - - return true; - } - - bool _areCoordsInsideTileBounds(Coords coords, Bounds? tileBounds) { - final bounds = tileBounds ?? _globalTileRange; - if ((coords.x < bounds.min.x || coords.x > bounds.max.x) || - (coords.y < bounds.min.y || coords.y > bounds.max.y)) { - return false; - } - return true; - } - - Bounds _latLngBoundsToPixelBounds(LatLngBounds bounds, double thisZoom) { - final swPixel = map.project(bounds.southWest!, thisZoom).floor(); - final nePixel = map.project(bounds.northEast!, thisZoom).ceil(); - final pxBounds = Bounds(swPixel, nePixel); - return pxBounds; - } - - String _tileCoordsToKey(Coords coords) { - return '${coords.x}:${coords.y}:${coords.z}'; - } - - //ignore: unused_element - Coords _keyToTileCoords(String key) { - final k = key.split(':'); - final coords = Coords(double.parse(k[0]), double.parse(k[1])); - coords.z = double.parse(k[2]); - - return coords; - } - - void _removeTile(String key) { - final tile = _tiles[key]; - if (tile == null) { - return; - } - - tile.dispose(tile.loadError && - options.evictErrorTileStrategy != EvictErrorTileStrategy.none); - _tiles.remove(key); - } - - void _addTile(Coords coords) { - final tileCoordsToKey = _tileCoordsToKey(coords); - final tile = _tiles[tileCoordsToKey] = Tile( - coords: coords, - coordsKey: tileCoordsToKey, - tilePos: _getTilePos(coords), - current: true, - level: _levels[coords.z]!, - imageProvider: - options.tileProvider.getImage(_wrapCoords(coords), options), - tileReady: _tileReady, - ); - - tile.loadTileImage(); - } - - void _evictErrorTilesBasedOnStrategy(Bounds tileRange) { - if (options.evictErrorTileStrategy == - EvictErrorTileStrategy.notVisibleRespectMargin) { - final toRemove = []; - for (final entry in _tiles.entries) { - final tile = entry.value; - - if (tile.loadError && !tile.current) { - toRemove.add(entry.key); - } - } - - for (final key in toRemove) { - final tile = _tiles[key]!; - - tile.dispose(true); - _tiles.remove(key); - } - } else if (options.evictErrorTileStrategy == - EvictErrorTileStrategy.notVisible) { - final toRemove = []; - for (final entry in _tiles.entries) { - final tile = entry.value; - final c = tile.coords; - - if (tile.loadError && - (!tile.current || !tileRange.contains(CustomPoint(c.x, c.y)))) { - toRemove.add(entry.key); - } - } - - for (final key in toRemove) { - final tile = _tiles[key]!; - - tile.dispose(true); - _tiles.remove(key); - } - } - } - - void _tileReady(Coords coords, dynamic error, Tile? tile) { - if (null != error) { - debugPrint(error.toString()); - - tile!.loadError = true; - - if (options.errorTileCallback != null) { - options.errorTileCallback!(tile, error); - } - } else { - tile!.loadError = false; - } - - final key = _tileCoordsToKey(coords); - tile = _tiles[key]; - if (null == tile) { - return; - } - - if (options.fastReplace && mounted) { - setState(() { - tile!.active = true; - - if (_noTilesToLoad()) { - // We're not waiting for anything, prune the tiles immediately. - _pruneTiles(); - } - }); - return; - } - - final fadeInStart = tile.loaded == null - ? options.tileFadeInStart - : options.tileFadeInStartWhenOverride; - tile.loaded = DateTime.now(); - if (options.tileFadeInDuration == null || - fadeInStart == 1.0 || - (tile.loadError && null == options.errorImage)) { - tile.active = true; - } else { - tile.startFadeInAnimation( - options.tileFadeInDuration!, - this, - from: fadeInStart, - ); - } - - if (mounted) { - setState(() {}); - } - - if (_noTilesToLoad()) { - // Wait a bit more than tileFadeInDuration (the duration of the tile - // fade-in) to trigger a pruning. - _pruneLater?.cancel(); - _pruneLater = Timer( - options.tileFadeInDuration != null - ? options.tileFadeInDuration! + const Duration(milliseconds: 50) - : const Duration(milliseconds: 50), - () { - if (mounted) { - setState(_pruneTiles); - } - }, - ); - } - } - - CustomPoint _getTilePos(Coords coords) { - final level = _levels[coords.z as double]!; - return coords.scaleBy(getTileSize()) - level.origin!; - } - - Coords _wrapCoords(Coords coords) { - final newCoords = Coords( - _wrapX != null - ? util.wrapNum(coords.x.toDouble(), _wrapX!) - : coords.x.toDouble(), - _wrapY != null - ? util.wrapNum(coords.y.toDouble(), _wrapY!) - : coords.y.toDouble(), - ); - newCoords.z = coords.z.toDouble(); - return newCoords; - } - - Bounds _pxBoundsToTileRange(Bounds bounds) { - final tileSize = getTileSize(); - return Bounds( - bounds.min.unscaleBy(tileSize).floor(), - bounds.max.unscaleBy(tileSize).ceil() - const CustomPoint(1, 1), - ); - } - - bool _noTilesToLoad() { - for (final entry in _tiles.entries) { - if (entry.value.loaded == null) { - return false; - } - } - return true; - } -} - -typedef TileReady = void Function( - Coords coords, dynamic error, Tile tile); - -class Tile implements Comparable { - final String coordsKey; - final Coords coords; - final CustomPoint tilePos; - ImageProvider imageProvider; - final Level level; - - bool current; - bool retain; - bool active; - bool loadError; - DateTime? loaded; - late DateTime loadStarted; - - AnimationController? animationController; - - double get opacity => animationController == null - ? (active ? 1.0 : 0.0) - : animationController!.value; - - // callback when tile is ready / error occurred - // it maybe be null for instance when download aborted - TileReady? tileReady; - ImageInfo? imageInfo; - ImageStream? _imageStream; - late ImageStreamListener _listener; - - Tile({ - required this.coordsKey, - required this.coords, - required this.tilePos, - required this.imageProvider, - this.tileReady, - required this.level, - this.current = false, - this.active = false, - this.retain = false, - this.loadError = false, - }); - - void loadTileImage() { - loadStarted = DateTime.now(); - - try { - final oldImageStream = _imageStream; - _imageStream = imageProvider.resolve(ImageConfiguration.empty); - - if (_imageStream!.key != oldImageStream?.key) { - oldImageStream?.removeListener(_listener); - - _listener = ImageStreamListener(_tileOnLoad, onError: _tileOnError); - _imageStream!.addListener(_listener); - } - } catch (e, s) { - // make sure all exception is handled - #444 / #536 - _tileOnError(e, s); - } - } - - // call this before GC! - void dispose([bool evict = false]) { - if (evict) { - try { - // ignore: return_type_invalid_for_catch_error - // ignore: implicit_dynamic_parameter - imageProvider.evict().catchError((e) { - debugPrint(e.toString()); - }); - } catch (e) { - // this may be never called because catchError will handle errors, however - // we want to avoid random crashes like in #444 / #536 - debugPrint(e.toString()); - } - } - - animationController?.removeStatusListener(_onAnimateEnd); - animationController?.dispose(); - _imageStream?.removeListener(_listener); - } - - void startFadeInAnimation(Duration duration, TickerProvider vsync, - {double? from}) { - animationController?.removeStatusListener(_onAnimateEnd); - - animationController = AnimationController(duration: duration, vsync: vsync) - ..addStatusListener(_onAnimateEnd); - - animationController!.forward(from: from); - } - - void _onAnimateEnd(AnimationStatus status) { - if (status == AnimationStatus.completed) { - active = true; - } - } - - void _tileOnLoad(ImageInfo imageInfo, bool synchronousCall) { - if (null != tileReady) { - this.imageInfo = imageInfo; - tileReady!(coords, null, this); - } - } - - void _tileOnError(dynamic exception, StackTrace? stackTrace) { - if (null != tileReady) { - tileReady!( - coords, exception ?? 'Unknown exception during loadTileImage', this); - } - } - - @override - int compareTo(Tile other) { - final zIndexA = level.zIndex; - final zIndexB = other.level.zIndex; - - if (zIndexA == zIndexB) { - return 0; - } else { - return zIndexB.compareTo(zIndexA); - } - } - - @override - int get hashCode => coords.hashCode; - - @override - bool operator ==(Object other) { - return other is Tile && coords == other.coords; - } -} - -class AnimatedTile extends StatefulWidget { - final Tile tile; - final ImageProvider? errorImage; - final TileBuilder? tileBuilder; - - const AnimatedTile({ - Key? key, - required this.tile, - this.errorImage, - required this.tileBuilder, - }) : super(key: key); - - @override - State createState() => _AnimatedTileState(); -} - -class _AnimatedTileState extends State { - bool listenerAttached = false; - - @override - Widget build(BuildContext context) { - final tileWidget = (widget.tile.loadError && widget.errorImage != null) - ? Image( - image: widget.errorImage!, - fit: BoxFit.fill, - ) - : RawImage( - image: widget.tile.imageInfo?.image, - fit: BoxFit.fill, - opacity: widget.tile.animationController); - - return widget.tileBuilder == null - ? tileWidget - : widget.tileBuilder!(context, tileWidget, widget.tile); - } - - @override - void initState() { - super.initState(); - - if (null != widget.tile.animationController) { - widget.tile.animationController!.addListener(_handleChange); - listenerAttached = true; - } - } - - @override - void dispose() { - if (listenerAttached) { - widget.tile.animationController?.removeListener(_handleChange); - } - - super.dispose(); - } - - @override - void didUpdateWidget(AnimatedTile oldWidget) { - super.didUpdateWidget(oldWidget); - - if (!listenerAttached && null != widget.tile.animationController) { - widget.tile.animationController!.addListener(_handleChange); - listenerAttached = true; - } - } - - void _handleChange() { - if (mounted) { - setState(() {}); - } - } -} - -class Level { - late double zIndex; - CustomPoint? origin; - late double zoom; - late CustomPoint translatePoint; - late double scale; -} - -class Coords extends CustomPoint { - late T z; - - Coords(T x, T y) : super(x, y); - - @override - String toString() => 'Coords($x, $y, $z)'; - - @override - bool operator ==(Object other) { - if (other is Coords) { - return x == other.x && y == other.y && z == other.z; - } - return false; - } - - @override - int get hashCode => hashValues(x.hashCode, y.hashCode, z.hashCode); -} diff --git a/lib/src/layer/tile_layer/animated_tile.dart b/lib/src/layer/tile_layer/animated_tile.dart new file mode 100644 index 000000000..53380f6eb --- /dev/null +++ b/lib/src/layer/tile_layer/animated_tile.dart @@ -0,0 +1,74 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; + +class AnimatedTile extends StatefulWidget { + final Tile tile; + final ImageProvider? errorImage; + final TileBuilder? tileBuilder; + + const AnimatedTile({ + Key? key, + required this.tile, + this.errorImage, + required this.tileBuilder, + }) : super(key: key); + + @override + State createState() => _AnimatedTileState(); +} + +class _AnimatedTileState extends State { + bool listenerAttached = false; + + @override + Widget build(BuildContext context) { + final tileWidget = (widget.tile.loadError && widget.errorImage != null) + ? Image( + image: widget.errorImage!, + fit: BoxFit.fill, + ) + : RawImage( + image: widget.tile.imageInfo?.image, + fit: BoxFit.fill, + opacity: widget.tile.animationController); + + return widget.tileBuilder == null + ? tileWidget + : widget.tileBuilder!(context, tileWidget, widget.tile); + } + + @override + void initState() { + super.initState(); + + if (null != widget.tile.animationController) { + widget.tile.animationController!.addListener(_handleChange); + listenerAttached = true; + } + } + + @override + void dispose() { + if (listenerAttached) { + widget.tile.animationController?.removeListener(_handleChange); + } + + super.dispose(); + } + + @override + void didUpdateWidget(AnimatedTile oldWidget) { + super.didUpdateWidget(oldWidget); + + if (!listenerAttached && null != widget.tile.animationController) { + widget.tile.animationController!.addListener(_handleChange); + listenerAttached = true; + } + } + + void _handleChange() { + if (mounted) { + setState(() {}); + } + } +} diff --git a/lib/src/layer/tile_layer/coords.dart b/lib/src/layer/tile_layer/coords.dart new file mode 100644 index 000000000..f927be44b --- /dev/null +++ b/lib/src/layer/tile_layer/coords.dart @@ -0,0 +1,37 @@ +import 'dart:ui'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/core/util.dart' as util; +import 'package:tuple/tuple.dart'; + +class Coords extends CustomPoint { + late T z; + + Coords(T x, T y) : super(x, y); + + Coords wrap( + Tuple2? wrapX, Tuple2? wrapY) { + final newCoords = Coords( + wrapX != null ? util.wrapNum(x.toDouble(), wrapX) : x.toDouble(), + wrapY != null ? util.wrapNum(y.toDouble(), wrapY) : y.toDouble(), + ); + newCoords.z = z.toDouble(); + return newCoords; + } + + String get key => '$x:$y:$z'; + + @override + String toString() => 'Coords($x, $y, $z)'; + + @override + bool operator ==(Object other) { + if (other is Coords) { + return x == other.x && y == other.y && z == other.z; + } + return false; + } + + @override + int get hashCode => hashValues(x.hashCode, y.hashCode, z.hashCode); +} diff --git a/lib/src/layer/tile_layer/level.dart b/lib/src/layer/tile_layer/level.dart new file mode 100644 index 000000000..6906ecc73 --- /dev/null +++ b/lib/src/layer/tile_layer/level.dart @@ -0,0 +1,11 @@ +import 'package:flutter_map/flutter_map.dart'; + +class Level { + final CustomPoint origin; + final double zoom; + + Level({ + required this.origin, + required this.zoom, + }); +} diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart new file mode 100644 index 000000000..157097d90 --- /dev/null +++ b/lib/src/layer/tile_layer/tile.dart @@ -0,0 +1,125 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; + +typedef TileReady = void Function( + Coords coords, dynamic error, Tile tile); + +class Tile { + final Coords coords; + final CustomPoint tilePos; + ImageProvider imageProvider; + + bool current; + bool retain; + bool active; + bool loadError; + DateTime? loaded; + late DateTime loadStarted; + + AnimationController? animationController; + + double get opacity => animationController == null + ? (active ? 1.0 : 0.0) + : animationController!.value; + + // callback when tile is ready / error occurred + // it maybe be null for instance when download aborted + TileReady? tileReady; + ImageInfo? imageInfo; + ImageStream? _imageStream; + late ImageStreamListener _listener; + + Tile({ + required this.coords, + required this.tilePos, + required this.imageProvider, + this.tileReady, + this.current = false, + this.active = false, + this.retain = false, + this.loadError = false, + }); + + void loadTileImage() { + loadStarted = DateTime.now(); + + try { + final oldImageStream = _imageStream; + _imageStream = imageProvider.resolve(ImageConfiguration.empty); + + if (_imageStream!.key != oldImageStream?.key) { + oldImageStream?.removeListener(_listener); + + _listener = ImageStreamListener(_tileOnLoad, onError: _tileOnError); + _imageStream!.addListener(_listener); + } + } catch (e, s) { + // make sure all exception is handled - #444 / #536 + _tileOnError(e, s); + } + } + + // call this before GC! + void dispose([bool evict = false]) { + if (evict) { + try { + // ignore: return_type_invalid_for_catch_error + // ignore: implicit_dynamic_parameter + imageProvider.evict().catchError((e) { + debugPrint(e.toString()); + }); + } catch (e) { + // this may be never called because catchError will handle errors, however + // we want to avoid random crashes like in #444 / #536 + debugPrint(e.toString()); + } + } + + animationController?.removeStatusListener(_onAnimateEnd); + animationController?.dispose(); + _imageStream?.removeListener(_listener); + } + + void startFadeInAnimation(Duration duration, TickerProvider vsync, + {double? from}) { + animationController?.removeStatusListener(_onAnimateEnd); + + animationController = AnimationController(duration: duration, vsync: vsync) + ..addStatusListener(_onAnimateEnd); + + animationController!.forward(from: from); + } + + void _onAnimateEnd(AnimationStatus status) { + if (status == AnimationStatus.completed) { + active = true; + } + } + + void _tileOnLoad(ImageInfo imageInfo, bool synchronousCall) { + if (null != tileReady) { + this.imageInfo = imageInfo; + tileReady!(coords, null, this); + } + } + + void _tileOnError(dynamic exception, StackTrace? stackTrace) { + if (null != tileReady) { + tileReady!( + coords, exception ?? 'Unknown exception during loadTileImage', this); + } + } + + String get coordsKey => coords.key; + + double zIndex(double maxZoom, double currentZoom) => + maxZoom - (currentZoom - coords.z).abs(); + + @override + int get hashCode => coords.hashCode; + + @override + bool operator ==(Object other) { + return other is Tile && coords == other.coords; + } +} diff --git a/lib/src/layer/tile_builder/tile_builder.dart b/lib/src/layer/tile_layer/tile_builder.dart similarity index 97% rename from lib/src/layer/tile_builder/tile_builder.dart rename to lib/src/layer/tile_layer/tile_builder.dart index 26f865fe8..27bbd0a8e 100644 --- a/lib/src/layer/tile_builder/tile_builder.dart +++ b/lib/src/layer/tile_layer/tile_builder.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/src/layer/tile_layer.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile.dart'; typedef TileBuilder = Widget Function( BuildContext context, Widget tileWidget, Tile tile); diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart new file mode 100644 index 000000000..bc1e737a7 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -0,0 +1,546 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart' show MapEquality; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/core/bounds.dart'; +import 'package:flutter_map/src/core/util.dart' as util; +import 'package:flutter_map/src/layer/tile_layer/level.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_manager.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_widget.dart'; +import 'package:flutter_map/src/layer/tile_layer/transformation_calculator.dart'; +import 'package:flutter_map/src/map/map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:tuple/tuple.dart'; + +part 'tile_layer_options.dart'; + +class TileLayerWidget extends StatelessWidget { + final TileLayerOptions options; + + const TileLayerWidget({Key? key, required this.options}) : super(key: key); + + @override + Widget build(BuildContext context) { + final mapState = MapState.maybeOf(context)!; + + return TileLayer( + mapState: mapState, + stream: mapState.onMoved, + options: options, + ); + } +} + +class TileLayer extends StatefulWidget { + final TileLayerOptions options; + final MapState mapState; + final Stream stream; + + TileLayer({ + required this.options, + required this.mapState, + required this.stream, + }) : super(key: options.key); + + @override + State createState() => _TileLayerState(); +} + +class _TileLayerState extends State with TickerProviderStateMixin { + MapState get map => widget.mapState; + + TileLayerOptions get options => widget.options; + late Bounds _globalTileRange; + Tuple2? _wrapX; + Tuple2? _wrapY; + double? _tileZoom; + + StreamSubscription? _moveSub; + StreamSubscription? _resetSub; + StreamController? _throttleUpdate; + late CustomPoint _tileSize; + + late final TileManager _tileManager; + late final TransformationCalculator _transformationCalculator; + + Timer? _pruneLater; + + @override + void initState() { + super.initState(); + _tileManager = TileManager(); + _transformationCalculator = TransformationCalculator(); + _tileSize = CustomPoint(options.tileSize, options.tileSize); + _resetView(); + _update(null); + _moveSub = widget.stream.listen((_) => _handleMove()); + + if (options.reset != null) { + _resetSub = options.reset?.listen((_) => _resetTiles()); + } + + _initThrottleUpdate(); + } + + @override + void didUpdateWidget(TileLayer oldWidget) { + super.didUpdateWidget(oldWidget); + var reloadTiles = false; + + if (oldWidget.options.tileSize != options.tileSize) { + _tileSize = CustomPoint(options.tileSize, options.tileSize); + reloadTiles = true; + } + + if (oldWidget.options.retinaMode != options.retinaMode) { + reloadTiles = true; + } + + reloadTiles |= + !_tileManager.allWithinZoom(options.minZoom, options.maxZoom); + + if (oldWidget.options.updateInterval != options.updateInterval) { + _throttleUpdate?.close(); + _initThrottleUpdate(); + } + + if (!reloadTiles) { + final oldUrl = oldWidget.options.wmsOptions?._encodedBaseUrl ?? + oldWidget.options.urlTemplate; + final newUrl = options.wmsOptions?._encodedBaseUrl ?? options.urlTemplate; + + final oldOptions = oldWidget.options.additionalOptions; + final newOptions = options.additionalOptions; + + if (oldUrl != newUrl || + !(const MapEquality()) + .equals(oldOptions, newOptions)) { + if (options.overrideTilesWhenUrlChanges) { + _tileManager.reloadImages(options, _wrapX, _wrapY); + } else { + reloadTiles = true; + } + } + } + + if (reloadTiles) { + _tileManager.removeAll(options.evictErrorTileStrategy); + _resetView(); + _update(null); + } + } + + void _initThrottleUpdate() { + if (options.updateInterval == null) { + _throttleUpdate = null; + } else { + _throttleUpdate = StreamController(sync: true); + _throttleUpdate!.stream + .transform( + util.throttleStreamTransformerWithTrailingCall( + options.updateInterval!, + ), + ) + .listen(_update); + } + } + + @override + void dispose() { + _tileManager.removeAll(options.evictErrorTileStrategy); + _resetSub?.cancel(); + _moveSub?.cancel(); + _pruneLater?.cancel(); + options.tileProvider.dispose(); + _throttleUpdate?.close(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: widget.stream, + builder: (context, snapshot) { + final tilesToRender = _tileZoom == null + ? _tileManager.all() + : _tileManager.sortedByDistanceToZoomAscending( + options.maxZoom, _tileZoom!); + final Map zoomToTransformation = {}; + + final tileWidgets = [ + for (var tile in tilesToRender) + TileWidget( + tile: tile, + size: _tileSize, + tileTransformation: zoomToTransformation[tile.coords.z] ?? + (zoomToTransformation[tile.coords.z] = + _transformationCalculator.transformationFor( + tile.coords.z, + map, + )), + errorImage: options.errorImage, + tileBuilder: options.tileBuilder, + key: ValueKey(tile.coordsKey), + ) + ]; + + final tilesContainer = Stack( + children: tileWidgets, + ); + + final tilesLayer = options.tilesContainerBuilder == null + ? tilesContainer + : options.tilesContainerBuilder!( + context, + tilesContainer, + tilesToRender, + ); + + final attributionLayer = + widget.options.attributionBuilder?.call(context); + + return Opacity( + opacity: options.opacity, + child: Container( + color: options.backgroundColor, + child: Stack( + alignment: widget.options.attributionAlignment, + children: [ + tilesLayer, + if (attributionLayer != null) attributionLayer, + ], + ), + ), + ); + }, + ); + } + + CustomPoint getTileSize() => _tileSize; + + Level? _updateLevels() { + final zoom = _tileZoom; + + if (zoom == null) return null; + + final toRemove = _transformationCalculator.whereLevel((levelZoom) => + levelZoom != zoom && !_tileManager.anyWithZoomLevel(levelZoom)); + + for (final z in toRemove) { + _tileManager.removeAtZoom(z, options.evictErrorTileStrategy); + _transformationCalculator.removeLevel(z); + } + + return _transformationCalculator.getOrCreateLevel(zoom, map); + } + + ///removes all loaded tiles and resets the view + void _resetTiles() { + _tileManager.removeAll(options.evictErrorTileStrategy); + _resetView(); + } + + void _resetView() { + _setView(map.center, map.zoom); + } + + double _clampZoom(double zoom) { + if (null != options.minNativeZoom && zoom < options.minNativeZoom!) { + return options.minNativeZoom!; + } + + if (null != options.maxNativeZoom && options.maxNativeZoom! < zoom) { + return options.maxNativeZoom!; + } + + return zoom; + } + + void _setView(LatLng center, double zoom) { + double? tileZoom = _clampZoom(zoom.roundToDouble()); + if ((tileZoom > options.maxZoom) || (tileZoom < options.minZoom)) { + tileZoom = null; + } + + _tileZoom = tileZoom; + + _tileManager.abortLoading(_tileZoom, options.evictErrorTileStrategy); + + _updateLevels(); + _resetGrid(); + + if (_tileZoom != null) { + _update(center); + } + + _tileManager.prune(_tileZoom, options.evictErrorTileStrategy); + } + + void _resetGrid() { + final map = this.map; + final crs = map.options.crs; + final tileSize = getTileSize(); + final tileZoom = _tileZoom; + + final bounds = map.getPixelWorldBounds(_tileZoom); + if (bounds != null) { + _globalTileRange = _pxBoundsToTileRange(bounds); + } + + // wrapping + _wrapX = crs.wrapLng; + if (_wrapX != null) { + final first = + (map.project(LatLng(0, crs.wrapLng!.item1), tileZoom).x / tileSize.x) + .floorToDouble(); + final second = + (map.project(LatLng(0, crs.wrapLng!.item2), tileZoom).x / tileSize.y) + .ceilToDouble(); + _wrapX = Tuple2(first, second); + } + + _wrapY = crs.wrapLat; + if (_wrapY != null) { + final first = + (map.project(LatLng(crs.wrapLat!.item1, 0), tileZoom).y / tileSize.x) + .floorToDouble(); + final second = + (map.project(LatLng(crs.wrapLat!.item2, 0), tileZoom).y / tileSize.y) + .ceilToDouble(); + _wrapY = Tuple2(first, second); + } + } + + void _handleMove() { + final tileZoom = _clampZoom(map.zoom.roundToDouble()); + + if (_tileZoom == null) { + // if there is no _tileZoom available it means we are out within zoom level + // we will restore fully via _setView call if we are back on trail + if ((tileZoom <= options.maxZoom) && (tileZoom >= options.minZoom)) { + _tileZoom = tileZoom; + setState(() { + _setView(map.center, tileZoom); + }); + } + } else { + setState(() { + if ((tileZoom - _tileZoom!).abs() >= 1) { + // It was a zoom lvl change + _setView(map.center, tileZoom); + } else { + if (_throttleUpdate == null) { + _update(null); + } else { + _throttleUpdate!.add(null); + } + } + }); + } + } + + Bounds _getTiledPixelBounds(LatLng center) { + final scale = map.getZoomScale(map.zoom, _tileZoom); + final pixelCenter = map.project(center, _tileZoom).floor(); + final halfSize = map.size / (scale * 2); + + return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); + } + + // Private method to load tiles in the grid's active zoom level according to + // map bounds + void _update(LatLng? center) { + if (_tileZoom == null) { + return; + } + + final zoom = _clampZoom(map.zoom); + center ??= map.center; + + final pixelBounds = _getTiledPixelBounds(center); + final tileRange = _pxBoundsToTileRange(pixelBounds); + final tileCenter = tileRange.center; + final queue = >[]; + final margin = options.keepBuffer; + final noPruneRange = Bounds( + tileRange.bottomLeft - CustomPoint(margin, -margin), + tileRange.topRight + CustomPoint(margin, -margin), + ); + + _tileManager.markToPrune(_tileZoom, noPruneRange); + + // _update just loads more tiles. If the tile zoom level differs too much + // from the map's, let _setView reset levels and prune old tiles. + if ((zoom - _tileZoom!).abs() > 1) { + _setView(center, zoom); + return; + } + + // create a queue of coordinates to load tiles from + for (var j = tileRange.min.y; j <= tileRange.max.y; j++) { + for (var i = tileRange.min.x; i <= tileRange.max.x; i++) { + final coords = Coords(i.toDouble(), j.toDouble()); + coords.z = _tileZoom!; + + if (options.tileBounds != null) { + final tilePxBounds = _pxBoundsToTileRange( + _latLngBoundsToPixelBounds(options.tileBounds!, _tileZoom!)); + if (!_areCoordsInsideTileBounds(coords, tilePxBounds)) { + continue; + } + } + + if (!_isValidTile(coords)) { + continue; + } + + if (!_tileManager.markTileWithCoordsAsCurrent(coords)) { + queue.add(coords); + } + } + } + + _tileManager.evictErrorTilesBasedOnStrategy( + tileRange, options.evictErrorTileStrategy); + + // sort tile queue to load tiles in order of their distance to center + queue.sort((a, b) => + (a.distanceTo(tileCenter) - b.distanceTo(tileCenter)).toInt()); + + for (final coords in queue) { + final newTile = Tile( + coords: coords, + tilePos: _getTilePos(coords), + current: true, + imageProvider: + options.tileProvider.getImage(coords.wrap(_wrapX, _wrapY), options), + tileReady: _tileReady, + ); + + _tileManager.add(coords, newTile); + // If we do this before adding the Tile to the TileManager the _tileReady + // callback may be fired very fast and we won't find the Tile in the + // TileManager since it's not added yet. + newTile.loadTileImage(); + } + } + + bool _isValidTile(Coords coords) { + final crs = map.options.crs; + + if (!crs.infinite) { + // don't load tile if it's out of bounds and not wrapped + final bounds = _globalTileRange; + if ((crs.wrapLng == null && + (coords.x < bounds.min.x || coords.x > bounds.max.x)) || + (crs.wrapLat == null && + (coords.y < bounds.min.y || coords.y > bounds.max.y))) { + return false; + } + } + + return true; + } + + bool _areCoordsInsideTileBounds(Coords coords, Bounds? tileBounds) { + final bounds = tileBounds ?? _globalTileRange; + if ((coords.x < bounds.min.x || coords.x > bounds.max.x) || + (coords.y < bounds.min.y || coords.y > bounds.max.y)) { + return false; + } + return true; + } + + Bounds _latLngBoundsToPixelBounds(LatLngBounds bounds, double thisZoom) { + final swPixel = map.project(bounds.southWest!, thisZoom).floor(); + final nePixel = map.project(bounds.northEast!, thisZoom).ceil(); + final pxBounds = Bounds(swPixel, nePixel); + return pxBounds; + } + + void _tileReady(Coords coords, dynamic error, Tile? tile) { + if (null != error) { + debugPrint(error.toString()); + + tile!.loadError = true; + + if (options.errorTileCallback != null) { + options.errorTileCallback!(tile, error); + } + } else { + tile!.loadError = false; + } + + tile = _tileManager.tileAt(tile.coords); + if (tile == null) return; + + if (options.fastReplace && mounted) { + setState(() { + tile!.active = true; + + if (_tileManager.allLoaded) { + // We're not waiting for anything, prune the tiles immediately. + _tileManager.prune(_tileZoom, options.evictErrorTileStrategy); + } + }); + return; + } + + final fadeInStart = tile.loaded == null + ? options.tileFadeInStart + : options.tileFadeInStartWhenOverride; + tile.loaded = DateTime.now(); + if (options.tileFadeInDuration == null || + fadeInStart == 1.0 || + (tile.loadError && null == options.errorImage)) { + tile.active = true; + } else { + tile.startFadeInAnimation( + options.tileFadeInDuration!, + this, + from: fadeInStart, + ); + } + + if (mounted) { + setState(() {}); + } + + if (_tileManager.allLoaded) { + // Wait a bit more than tileFadeInDuration (the duration of the tile + // fade-in) to trigger a pruning. + _pruneLater?.cancel(); + _pruneLater = Timer( + options.tileFadeInDuration != null + ? options.tileFadeInDuration! + const Duration(milliseconds: 50) + : const Duration(milliseconds: 50), + () { + if (mounted) { + setState(() { + _tileManager.prune(_tileZoom, options.evictErrorTileStrategy); + }); + } + }, + ); + } + } + + CustomPoint _getTilePos(Coords coords) { + final level = + _transformationCalculator.getOrCreateLevel(coords.z as double, map); + return coords.scaleBy(getTileSize()) - level.origin; + } + + Bounds _pxBoundsToTileRange(Bounds bounds) { + final tileSize = getTileSize(); + return Bounds( + bounds.min.unscaleBy(tileSize).floor(), + bounds.max.unscaleBy(tileSize).ceil() - const CustomPoint(1, 1), + ); + } +} diff --git a/lib/src/layer/tile_layer/tile_layer_options.dart b/lib/src/layer/tile_layer/tile_layer_options.dart new file mode 100644 index 000000000..5dc4aec92 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_layer_options.dart @@ -0,0 +1,402 @@ +part of 'tile_layer.dart'; + +typedef TemplateFunction = String Function( + String str, Map data); + +enum EvictErrorTileStrategy { + // never evict error Tiles + none, + // evict error Tiles during _pruneTiles / _abortLoading calls + dispose, + // evict error Tiles which are not visible anymore but respect margin (see keepBuffer option) + // (Tile's zoom level not equals current _tileZoom or Tile is out of viewport) + notVisibleRespectMargin, + // evict error Tiles which are not visible anymore + // (Tile's zoom level not equals current _tileZoom or Tile is out of viewport) + notVisible, +} + +typedef ErrorTileCallBack = void Function(Tile tile, dynamic error); + +/// Describes the needed properties to create a tile-based layer. A tile is an +/// image bound to a specific geographical position. +class TileLayerOptions extends LayerOptions { + /// Defines the structure to create the URLs for the tiles. `{s}` means one of + /// the available subdomains (can be omitted) `{z}` zoom level `{x}` and `{y}` + /// — tile coordinates `{r}` can be used to add "@2x" to the URL to + /// load retina tiles (can be omitted) + /// + /// Example: + /// + /// https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png + /// + /// Is translated to this: + /// + /// https://a.tile.openstreetmap.org/12/2177/1259.png + final String? urlTemplate; + + /// If `true`, inverses Y axis numbering for tiles (turn this on for + /// [TMS](https://en.wikipedia.org/wiki/Tile_Map_Service) services). + final bool tms; + + /// If not `null`, then tiles will pull's WMS protocol requests + final WMSTileLayerOptions? wmsOptions; + + /// Size for the tile. + /// Default is 256 + final double tileSize; + + // The minimum zoom level down to which this layer will be + // displayed (inclusive). + final double minZoom; + + /// The maximum zoom level up to which this layer will be displayed + /// (inclusive). In most tile providers goes from 0 to 19. + final double maxZoom; + + /// Minimum zoom number the tile source has available. If it is specified, the + /// tiles on all zoom levels lower than minNativeZoom will be loaded from + /// minNativeZoom level and auto-scaled. + final double? minNativeZoom; + + /// Maximum zoom number the tile source has available. If it is specified, the + /// tiles on all zoom levels higher than maxNativeZoom will be loaded from + /// maxNativeZoom level and auto-scaled. + final double? maxNativeZoom; + + /// If set to true, the zoom number used in tile URLs will be reversed + /// (`maxZoom - zoom` instead of `zoom`) + final bool zoomReverse; + + /// The zoom number used in tile URLs will be offset with this value. + final double zoomOffset; + + /// List of subdomains for the URL. + /// + /// Example: + /// + /// Subdomains = {a,b,c} + /// + /// and the URL is as follows: + /// + /// https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png + /// + /// then: + /// + /// https://a.tile.openstreetmap.org/{z}/{x}/{y}.png + /// https://b.tile.openstreetmap.org/{z}/{x}/{y}.png + /// https://c.tile.openstreetmap.org/{z}/{x}/{y}.png + final List subdomains; + + /// Color shown behind the tiles. + final Color backgroundColor; + + /// Opacity of the rendered tile + final double opacity; + + /// Provider to load the tiles. The default is `NonCachingNetworkTileProvider()` which + /// doesn't cache tiles and won't retry the HTTP request. Use `NetworkTileProvider()` for + /// a provider which will retry requests. For the best caching implementations, see the + /// flutter_map readme. + /// + /// In order to use images from the asset folder set this option to + /// AssetTileProvider() Note that it requires the urlTemplate to target + /// assets, for example: + /// + /// ```dart + /// urlTemplate: "assets/map/anholt_osmbright/{z}/{x}/{y}.png", + /// ``` + /// + /// In order to use images from the filesystem set this option to + /// FileTileProvider() Note that it requires the urlTemplate to target the + /// file system, for example: + /// + /// ```dart + /// urlTemplate: "/storage/emulated/0/tiles/some_place/{z}/{x}/{y}.png", + /// ``` + /// + /// Furthermore you create your custom implementation by subclassing + /// TileProvider + final TileProvider tileProvider; + + /// When panning the map, keep this many rows and columns of tiles before + /// unloading them. + final int keepBuffer; + + /// Placeholder to show until tile images are fetched by the provider. + final ImageProvider? placeholderImage; + + /// Tile image to show in place of the tile that failed to load. + final ImageProvider? errorImage; + + /// Static information that should replace placeholders in the [urlTemplate]. + /// Applying API keys is a good example on how to use this parameter. + /// + /// Example: + /// + /// ```dart + /// + /// TileLayerOptions( + /// urlTemplate: "https://api.tiles.mapbox.com/v4/" + /// "{id}/{z}/{x}/{y}{r}.png?access_token={accessToken}", + /// additionalOptions: { + /// 'accessToken': '', + /// 'id': 'mapbox.streets', + /// }, + /// ), + /// ``` + final Map additionalOptions; + + /// Tiles will not update more than once every `updateInterval` (default 200 + /// milliseconds) when panning. It can be null (but it will calculating for + /// loading tiles every frame when panning / zooming, flutter is fast) This + /// can save some fps and even bandwidth (ie. when fast panning / animating + /// between long distances in short time) + final Duration? updateInterval; + + /// Tiles fade in duration in milliseconds (default 100). This can be null to + /// avoid fade in. + final Duration? tileFadeInDuration; + + /// Opacity start value when Tile starts fade in (0.0 - 1.0) Takes effect if + /// `tileFadeInDuration` is not null + final double tileFadeInStart; + + /// Opacity start value when an exists Tile starts fade in with different Url + /// (0.0 - 1.0) Takes effect when `tileFadeInDuration` is not null and if + /// `overrideTilesWhenUrlChanges` if true + final double tileFadeInStartWhenOverride; + + /// `false`: current Tiles will be first dropped and then reload via new url + /// (default) `true`: current Tiles will be visible until new ones aren't + /// loaded (new Tiles are loaded independently) @see + /// https://github.com/johnpryan/flutter_map/issues/583 + final bool overrideTilesWhenUrlChanges; + + /// If `true`, it will request four tiles of half the specified size and a + /// bigger zoom level in place of one to utilize the high resolution. + /// + /// If `true` then MapOptions's `maxZoom` should be `maxZoom - 1` since + /// retinaMode just simulates retina display by playing with `zoomOffset`. If + /// geoserver supports retina `@2` tiles then it it advised to use them + /// instead of simulating it (use {r} in the [urlTemplate]) + /// + /// It is advised to use retinaMode if display supports it, write code like + /// this: + /// + /// ```dart + /// TileLayerOptions( + /// retinaMode: true && MediaQuery.of(context).devicePixelRatio > 1.0, + /// ), + /// ``` + final bool retinaMode; + + /// This callback will be execute if some errors occur when fetching tiles. + final ErrorTileCallBack? errorTileCallback; + + final TemplateFunction templateFunction; + + /// Function which may Wrap Tile with custom Widget + /// There are predefined examples in 'tile_builder.dart' + final TileBuilder? tileBuilder; + + /// Function which may wrap Tiles Container with custom Widget + /// There are predefined examples in 'tile_builder.dart' + final TilesContainerBuilder? tilesContainerBuilder; + + // If a Tile was loaded with error and if strategy isn't `none` then TileProvider + // will be asked to evict Image based on current strategy + // (see #576 - even Error Images are cached in flutter) + final EvictErrorTileStrategy evictErrorTileStrategy; + + /// This option is useful when you have a transparent layer: rather than + /// keeping the old layer visible when zooming (resulting in both layers + /// being temporarily visible), the old layer is removed as quickly as + /// possible when this is set to `true` (default `false`). + /// + /// This option is likely to cause some flickering of the transparent layer, + /// most noticeable when using pinch-to-zoom. It's best used with maps that + /// have `interactive` set to `false`, and zoom using buttons that call + /// `MapController.move()`. + /// + /// When set to `true`, the `tileFadeIn*` options will be ignored. + final bool fastReplace; + + ///Attribution widget builder + final WidgetBuilder? attributionBuilder; + + ///aligment of the attribution text on the map widget + final Alignment attributionAlignment; + + /// Stream to notify the [TileLayer] that it needs resetting + Stream? reset; + + /// Only load tiles that are within these bounds + LatLngBounds? tileBounds; + + TileLayerOptions( + {this.attributionAlignment = Alignment.bottomRight, + this.attributionBuilder, + Key? key, + // TODO: make required + this.urlTemplate, + double tileSize = 256.0, + double minZoom = 0.0, + double maxZoom = 18.0, + this.minNativeZoom, + this.maxNativeZoom, + this.zoomReverse = false, + double zoomOffset = 0.0, + Map? additionalOptions, + this.subdomains = const [], + this.keepBuffer = 2, + this.backgroundColor = const Color(0xFFE0E0E0), + this.placeholderImage, + this.errorImage, + this.tileProvider = const NonCachingNetworkTileProvider(), + this.tms = false, + // ignore: avoid_init_to_null + this.wmsOptions = null, + this.opacity = 1.0, + // Tiles will not update more than once every `updateInterval` milliseconds + // (default 200) when panning. It can be 0 (but it will calculating for + // loading tiles every frame when panning / zooming, flutter is fast) This + // can save some fps and even bandwidth (ie. when fast panning / animating + // between long distances in short time) + // TODO: change to Duration + int updateInterval = 200, + // Tiles fade in duration in milliseconds (default 100). This can be set to + // 0 to avoid fade in + // TODO: change to Duration + int tileFadeInDuration = 100, + this.tileFadeInStart = 0.0, + this.tileFadeInStartWhenOverride = 0.0, + this.overrideTilesWhenUrlChanges = false, + this.retinaMode = false, + this.errorTileCallback, + Stream? rebuild, + this.templateFunction = util.template, + this.tileBuilder, + this.tilesContainerBuilder, + this.evictErrorTileStrategy = EvictErrorTileStrategy.none, + this.fastReplace = false, + this.reset, + this.tileBounds}) + : updateInterval = + updateInterval <= 0 ? null : Duration(milliseconds: updateInterval), + tileFadeInDuration = tileFadeInDuration <= 0 + ? null + : Duration(milliseconds: tileFadeInDuration), + assert(tileFadeInStart >= 0.0 && tileFadeInStart <= 1.0), + assert(tileFadeInStartWhenOverride >= 0.0 && + tileFadeInStartWhenOverride <= 1.0), + maxZoom = + wmsOptions == null && retinaMode && maxZoom > 0.0 && !zoomReverse + ? maxZoom - 1.0 + : maxZoom, + minZoom = + wmsOptions == null && retinaMode && maxZoom > 0.0 && zoomReverse + ? math.max(minZoom + 1.0, 0) + : minZoom, + zoomOffset = wmsOptions == null && retinaMode && maxZoom > 0.0 + ? (zoomReverse ? zoomOffset - 1.0 : zoomOffset + 1.0) + : zoomOffset, + tileSize = wmsOptions == null && retinaMode && maxZoom > 0.0 + ? (tileSize / 2.0).floorToDouble() + : tileSize, + // copy additionalOptions Map if not null, so we can safely compare old + // and new Map inside didUpdateWidget with MapEquality. + additionalOptions = additionalOptions == null + ? const {} + : Map.from(additionalOptions), + super(key: key, rebuild: rebuild); +} + +class WMSTileLayerOptions { + final service = 'WMS'; + final request = 'GetMap'; + + /// url of WMS service. + /// Ex.: 'http://ows.mundialis.de/services/service?' + final String baseUrl; + + /// list of WMS layers to show + final List layers; + + /// list of WMS styles + final List styles; + + /// WMS image format (use 'image/png' for layers with transparency) + final String format; + + /// Version of the WMS service to use + final String version; + + /// tile transparency flag + final bool transparent; + + /// Encode boolean values as uppercase in request + final bool uppercaseBoolValue; + + // TODO find a way to implicit pass of current map [Crs] + final Crs crs; + + /// other request parameters + final Map otherParameters; + + late final String _encodedBaseUrl; + + late final double _versionNumber; + + WMSTileLayerOptions({ + required this.baseUrl, + this.layers = const [], + this.styles = const [], + this.format = 'image/png', + this.version = '1.1.1', + this.transparent = true, + this.uppercaseBoolValue = false, + this.crs = const Epsg3857(), + this.otherParameters = const {}, + }) { + _versionNumber = double.tryParse(version.split('.').take(2).join('.')) ?? 0; + _encodedBaseUrl = _buildEncodedBaseUrl(); + } + + String _buildEncodedBaseUrl() { + final projectionKey = _versionNumber >= 1.3 ? 'crs' : 'srs'; + final buffer = StringBuffer(baseUrl) + ..write('&service=$service') + ..write('&request=$request') + ..write('&layers=${layers.map(Uri.encodeComponent).join(',')}') + ..write('&styles=${styles.map(Uri.encodeComponent).join(',')}') + ..write('&format=${Uri.encodeComponent(format)}') + ..write('&$projectionKey=${Uri.encodeComponent(crs.code)}') + ..write('&version=${Uri.encodeComponent(version)}') + ..write( + '&transparent=${uppercaseBoolValue ? transparent.toString().toUpperCase() : transparent}'); + otherParameters + .forEach((k, v) => buffer.write('&$k=${Uri.encodeComponent(v)}')); + return buffer.toString(); + } + + String getUrl(Coords coords, int tileSize, bool retinaMode) { + final tileSizePoint = CustomPoint(tileSize, tileSize); + final nvPoint = coords.scaleBy(tileSizePoint); + final sePoint = nvPoint + tileSizePoint; + final nvCoords = crs.pointToLatLng(nvPoint, coords.z as double)!; + final seCoords = crs.pointToLatLng(sePoint, coords.z as double)!; + final nv = crs.projection.project(nvCoords); + final se = crs.projection.project(seCoords); + final bounds = Bounds(nv, se); + final bbox = (_versionNumber >= 1.3 && crs is Epsg4326) + ? [bounds.min.y, bounds.min.x, bounds.max.y, bounds.max.x] + : [bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y]; + + final buffer = StringBuffer(_encodedBaseUrl); + buffer.write('&width=${retinaMode ? tileSize * 2 : tileSize}'); + buffer.write('&height=${retinaMode ? tileSize * 2 : tileSize}'); + buffer.write('&bbox=${bbox.join(',')}'); + return buffer.toString(); + } +} diff --git a/lib/src/layer/tile_layer/tile_manager.dart b/lib/src/layer/tile_layer/tile_manager.dart new file mode 100644 index 000000000..408435a3d --- /dev/null +++ b/lib/src/layer/tile_layer/tile_manager.dart @@ -0,0 +1,257 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/core/bounds.dart'; +import 'package:tuple/tuple.dart'; + +class TileManager { + final Map _tiles = {}; + + List all() => _tiles.values.toList(); + + List sortedByDistanceToZoomAscending( + double maxZoom, double currentZoom) { + return [..._tiles.values]..sort((a, b) => a + .zIndex(maxZoom, currentZoom) + .compareTo(b.zIndex(maxZoom, currentZoom))); + } + + bool anyWithZoomLevel(double zoomLevel) { + for (final tile in _tiles.values) { + if (tile.coords.z == zoomLevel) { + return true; + } + } + + return false; + } + + Tile? tileAt(Coords coords) => _tiles[coords.key]; + + bool get allLoaded { + for (final entry in _tiles.entries) { + if (entry.value.loaded == null) { + return false; + } + } + return true; + } + + bool allWithinZoom(double minZoom, double maxZoom) { + for (final tile in _tiles.values) { + if (tile.coords.z > (maxZoom) || tile.coords.z < (minZoom)) { + return false; + } + } + return true; + } + + bool markTileWithCoordsAsCurrent(Coords coords) { + final tile = _tiles[coords.key]; + if (tile != null) { + tile.current = true; + return true; + } else { + return false; + } + } + + void add(Coords coords, Tile tile) { + _tiles[coords.key] = tile; + } + + void remove(String key, EvictErrorTileStrategy evictStrategy) { + final tile = _tiles[key]; + if (tile == null) { + return; + } + + tile.dispose( + tile.loadError && evictStrategy != EvictErrorTileStrategy.none); + + _tiles.remove(key); + } + + void removeAll(EvictErrorTileStrategy evictStrategy) { + final toRemove = Map.from(_tiles); + + for (final key in toRemove.keys) { + remove(key, evictStrategy); + } + } + + void removeAtZoom(double zoom, EvictErrorTileStrategy evictStrategy) { + final toRemove = []; + for (final entry in _tiles.entries) { + if (entry.value.coords.z != zoom) { + continue; + } + toRemove.add(entry.key); + } + + for (final key in toRemove) { + remove(key, evictStrategy); + } + } + + void reloadImages( + TileLayerOptions options, + Tuple2? wrapX, + Tuple2? wrapY, + ) { + for (final tile in _tiles.values) { + tile.imageProvider = options.tileProvider + .getImage(tile.coords.wrap(wrapX, wrapY), options); + tile.loadTileImage(); + } + } + + void abortLoading(double? tileZoom, EvictErrorTileStrategy evictionStrategy) { + final toRemove = []; + for (final entry in _tiles.entries) { + final tile = entry.value; + + if (tile.coords.z != tileZoom && tile.loaded == null) { + toRemove.add(entry.key); + } + } + + for (final key in toRemove) { + final tile = _tiles[key]!; + + tile.tileReady = null; + tile.dispose( + tile.loadError && evictionStrategy != EvictErrorTileStrategy.none); + _tiles.remove(key); + } + } + + void markToPrune(double? currentZoom, Bounds noPruneRange) { + for (final entry in _tiles.entries) { + final tile = entry.value; + final c = tile.coords; + + if (tile.current && + (c.z != currentZoom || + !noPruneRange.contains(CustomPoint(c.x, c.y)))) { + tile.current = false; + } + } + } + + void evictErrorTilesBasedOnStrategy( + Bounds tileRange, EvictErrorTileStrategy evictStrategy) { + if (evictStrategy == EvictErrorTileStrategy.notVisibleRespectMargin) { + final toRemove = []; + for (final entry in _tiles.entries) { + final tile = entry.value; + + if (tile.loadError && !tile.current) { + toRemove.add(entry.key); + } + } + + for (final key in toRemove) { + _tiles[key]!.dispose(true); + _tiles.remove(key); + } + } else if (evictStrategy == EvictErrorTileStrategy.notVisible) { + final toRemove = []; + for (final entry in _tiles.entries) { + final tile = entry.value; + final c = tile.coords; + + if (tile.loadError && + (!tile.current || !tileRange.contains(CustomPoint(c.x, c.y)))) { + toRemove.add(entry.key); + } + } + + for (final key in toRemove) { + _tiles[key]!.dispose(true); + _tiles.remove(key); + } + } + } + + void prune(double? zoom, EvictErrorTileStrategy evictStrategy) { + if (zoom == null) { + removeAll(evictStrategy); + return; + } + + for (final entry in _tiles.entries) { + final tile = entry.value; + tile.retain = tile.current; + } + + for (final entry in _tiles.entries) { + final tile = entry.value; + + if (tile.current && !tile.active) { + final coords = tile.coords; + if (!_retainParent(coords.x, coords.y, coords.z, coords.z - 5)) { + _retainChildren(coords.x, coords.y, coords.z, coords.z + 2); + } + } + } + + final toRemove = []; + for (final entry in _tiles.entries) { + final tile = entry.value; + + if (!tile.retain) { + toRemove.add(entry.key); + } + } + + for (final key in toRemove) { + remove(key, evictStrategy); + } + } + + void _retainChildren(double x, double y, double z, double maxZoom) { + for (var i = 2 * x; i < 2 * x + 2; i++) { + for (var j = 2 * y; j < 2 * y + 2; j++) { + final coords = Coords(i, j); + coords.z = z + 1; + + final tile = _tiles[coords.key]; + if (tile != null) { + if (tile.active) { + tile.retain = true; + continue; + } else if (tile.loaded != null) { + tile.retain = true; + } + } + + if (z + 1 < maxZoom) { + _retainChildren(i, j, z + 1, maxZoom); + } + } + } + } + + bool _retainParent(double x, double y, double z, double minZoom) { + final x2 = (x / 2).floorToDouble(); + final y2 = (y / 2).floorToDouble(); + final z2 = z - 1; + final coords2 = Coords(x2, y2); + coords2.z = z2; + + final tile = _tiles[coords2.key]; + if (tile != null) { + if (tile.active) { + tile.retain = true; + return true; + } else if (tile.loaded != null) { + tile.retain = true; + } + } + + if (z2 > minZoom) { + return _retainParent(x2, y2, z2, minZoom); + } + + return false; + } +} diff --git a/lib/src/layer/tile_provider/file_tile_provider_io.dart b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart similarity index 70% rename from lib/src/layer/tile_provider/file_tile_provider_io.dart rename to lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart index 174f8f518..564345245 100644 --- a/lib/src/layer/tile_provider/file_tile_provider_io.dart +++ b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_io.dart @@ -1,13 +1,12 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; - -import 'package:flutter_map/src/layer/tile_layer.dart'; -import 'package:flutter_map/src/layer/tile_provider/tile_provider.dart'; +import 'package:flutter_map/flutter_map.dart'; /// FileTileProvider class FileTileProvider extends TileProvider { const FileTileProvider(); + @override ImageProvider getImage(Coords coords, TileLayerOptions options) { return FileImage(File(getTileUrl(coords, options))); diff --git a/lib/src/layer/tile_provider/file_tile_provider_web.dart b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart similarity index 68% rename from lib/src/layer/tile_provider/file_tile_provider_web.dart rename to lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart index eab46a5eb..31da2b069 100644 --- a/lib/src/layer/tile_provider/file_tile_provider_web.dart +++ b/lib/src/layer/tile_layer/tile_provider/file_tile_provider_web.dart @@ -1,12 +1,11 @@ import 'package:flutter/widgets.dart'; - -import 'package:flutter_map/src/layer/tile_layer.dart'; -import 'package:flutter_map/src/layer/tile_provider/tile_provider.dart'; +import 'package:flutter_map/flutter_map.dart'; /// FileTileProvider class FileTileProvider extends TileProvider { const FileTileProvider(); + @override ImageProvider getImage(Coords coords, TileLayerOptions options) { return NetworkImage(getTileUrl(coords, options)); diff --git a/lib/src/layer/tile_provider/network_image_with_retry.dart b/lib/src/layer/tile_layer/tile_provider/network_image_with_retry.dart similarity index 100% rename from lib/src/layer/tile_provider/network_image_with_retry.dart rename to lib/src/layer/tile_layer/tile_provider/network_image_with_retry.dart diff --git a/lib/src/layer/tile_provider/tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/tile_provider.dart similarity index 96% rename from lib/src/layer/tile_provider/tile_provider.dart rename to lib/src/layer/tile_layer/tile_provider/tile_provider.dart index e0b055de4..4ffddef66 100644 --- a/lib/src/layer/tile_provider/tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/tile_provider.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; - -import 'package:flutter_map/src/layer/tile_provider/network_image_with_retry.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_image_with_retry.dart'; abstract class TileProvider { const TileProvider(); @@ -65,6 +64,7 @@ class NetworkTileProvider extends TileProvider { class NonCachingNetworkTileProvider extends TileProvider { const NonCachingNetworkTileProvider(); + @override ImageProvider getImage(Coords coords, TileLayerOptions options) { return NetworkImage(getTileUrl(coords, options)); @@ -73,6 +73,7 @@ class NonCachingNetworkTileProvider extends TileProvider { class AssetTileProvider extends TileProvider { const AssetTileProvider(); + @override ImageProvider getImage(Coords coords, TileLayerOptions options) { return AssetImage(getTileUrl(coords, options)); diff --git a/lib/src/layer/tile_layer/tile_transformation.dart b/lib/src/layer/tile_layer/tile_transformation.dart new file mode 100644 index 000000000..8cb3fe217 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_transformation.dart @@ -0,0 +1,13 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:meta/meta.dart'; + +@immutable +class TileTransformation { + final double scale; + final CustomPoint translate; + + const TileTransformation({ + required this.scale, + required this.translate, + }); +} diff --git a/lib/src/layer/tile_layer/tile_widget.dart b/lib/src/layer/tile_layer/tile_widget.dart new file mode 100644 index 000000000..68af0cdcc --- /dev/null +++ b/lib/src/layer/tile_layer/tile_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/animated_tile.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; + +class TileWidget extends StatelessWidget { + final Tile tile; + final CustomPoint size; + final TileTransformation tileTransformation; + final ImageProvider? errorImage; + final TileBuilder? tileBuilder; + + const TileWidget({ + required this.tile, + required this.size, + required this.tileTransformation, + required this.errorImage, + required this.tileBuilder, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final pos = tile.tilePos.multiplyBy(tileTransformation.scale) + + tileTransformation.translate; + final num width = size.x * tileTransformation.scale; + final num height = size.y * tileTransformation.scale; + + return Positioned( + left: pos.x.toDouble(), + top: pos.y.toDouble(), + width: width.toDouble(), + height: height.toDouble(), + child: AnimatedTile( + tile: tile, + errorImage: errorImage, + tileBuilder: tileBuilder, + ), + ); + } +} diff --git a/lib/src/layer/tile_layer/transformation_calculator.dart b/lib/src/layer/tile_layer/transformation_calculator.dart new file mode 100644 index 000000000..6f1419198 --- /dev/null +++ b/lib/src/layer/tile_layer/transformation_calculator.dart @@ -0,0 +1,40 @@ +import 'package:flutter_map/src/layer/tile_layer/level.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_transformation.dart'; +import 'package:flutter_map/src/map/map.dart'; + +class TransformationCalculator { + final Map _levels = {}; + + Level? levelAt(double zoom) => _levels[zoom]; + + Level getOrCreateLevel(double zoom, MapState map) { + final level = _levels[zoom]; + if (level != null) return level; + + return _levels[zoom] = Level( + origin: map.project(map.unproject(map.getPixelOrigin()), zoom), + zoom: zoom, + ); + } + + List whereLevel(bool Function(double level) test) { + final result = []; + for (final levelZoom in _levels.keys) { + if (test(levelZoom)) result.add(levelZoom); + } + + return result; + } + + void removeLevel(double levelZoom) { + _levels.remove(levelZoom); + } + + TileTransformation transformationFor(double levelZoom, MapState map) { + final level = _levels[levelZoom]!; + final scale = map.getZoomScale(map.zoom, level.zoom); + final pixelOrigin = map.getNewPixelOrigin(map.center, map.zoom).round(); + final translate = level.origin.multiplyBy(scale) - pixelOrigin; + return TileTransformation(scale: scale, translate: translate); + } +} diff --git a/lib/src/map/map.dart b/lib/src/map/map.dart index 0b434ef67..8897ef906 100644 --- a/lib/src/map/map.dart +++ b/lib/src/map/map.dart @@ -10,6 +10,7 @@ import 'package:latlong2/latlong.dart'; class MapControllerImpl implements MapController { final Completer _readyCompleter = Completer(); final StreamController _mapEventSink = StreamController.broadcast(); + @override StreamSink get mapEventSink => _mapEventSink.sink; @@ -22,6 +23,7 @@ class MapControllerImpl implements MapController { } late final MapState _state; + @override set state(MapState state) { _state = state; @@ -129,6 +131,7 @@ class MapState { double _rotationRad; double get zoom => _zoom; + double get rotation => _rotation; set rotation(double rotation) {