fleaflet / flutter_map

A versatile mapping package for Flutter. Simple and easy to learn, yet completely customizable and configurable, it's the best choice for mapping in your Flutter app.
https://pub.dev/packages/flutter_map
BSD 3-Clause "New" or "Revised" License
2.73k stars 859 forks source link

[BUG] TileLayer fails to load available tile when zoom is too high #1968

Open ChristopheLyonnaz opened 2 hours ago

ChristopheLyonnaz commented 2 hours ago

What is the bug?

TileLayer should be able to load tile of lower resolution if the tile provider cannot provide the tile at required zoom.

When TileLayer loads a tile at zoom Z, if the user zooms in, TileLayer gets the tile at zoom Z+1. if the tile provider cannot deliver this tile, or if the maxNativeZoom is defined as Z, TileLayer scales up the latest available tile (at zoom Z) and displays it correctly. However, in this situation (where a tile is scaled up), if the user scrolls to adjacent tiles, TileLayer fails to recover the tile at zoom Z to scale it up and display it at the new position. It returns an error and displays a grey area, or the fallback strategy.

How can we reproduce it?

Create a map layer with a maxNativeZoom greater than the maximum zoom available in your tile provider. Zoom on a area to display a tile with the maximum zoom. Continue zooming to have this tile scaled up. Scroll to adjacent tile.

TileLayer fails to get the adjacent tile and returns an exception:

======== Exception caught by image resource service ================================================
The following ClientException was thrown resolving an image codec:
Request to https://tile.openstreetmap.org/20/521267/378404.png failed with status 400: Bad Request., uri=https://tile.openstreetmap.org/20/521267/378404.png

When the exception was thrown, this was the stack: 
#0      BaseClient._checkResponseSuccess (package:http/src/base_client.dart:103:5)
#1      BaseClient.readBytes (package:http/src/base_client.dart:59:5)
<asynchronous suspension>
#2      NetworkTileProvider.getImage.<anonymous closure> (package:flutter_map/src/layer/tile_layer/tile_provider/network_tile_provider.dart:68:31)
<asynchronous suspension>
#3      ImmutableBuffer.fromUint8List (dart:ui/painting.dart:6667:3)
<asynchronous suspension>
#4      PaintingBinding.instantiateImageCodecWithSize (package:flutter/src/painting/binding.dart:137:3)
<asynchronous suspension>
#5      MultiFrameImageStreamCompleter._handleCodecReady (package:flutter/src/painting/image_stream.dart:1005:3)
<asynchronous suspension>
URL: https://tile.openstreetmap.org/20/521267/378404.png
Fallback URL: null
Current provider: MapNetworkImageProvider()
====================================================================================================

Here is an example of code (inspired from 'Sliding Map' flutter_map example):

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart';
import 'package:latlong2/latlong.dart';

class SlidingMapPage extends StatelessWidget {
  static const String route = '/sliding_map';
  static const northEast = LatLng(56.7378, 11.6644);
  static const southWest = LatLng(56.6877, 11.5089);

  const SlidingMapPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sliding Map')),
      drawer: const MenuDrawer(route),
      body: Column(
        children: [
          const Padding(
            padding: EdgeInsets.all(8),
            child: Text(
              'This is a map that can be panned smoothly when the '
              'boundaries are reached.',
            ),
          ),
          Flexible(
            child: FlutterMap(
              options: const MapOptions(
                initialCenter: LatLng(44.704173, -1.043808),
                minZoom: 1,
              ),
              children: [
                TileLayer(
                  urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
                  maxNativeZoom: 30,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Do you have a potential solution?

Not yet a solution, but I suggest that _onTileUpdateEvent() function may be able to check for max available zoom from provider and updates tileZoom accordingly.

Currently, it only clamps zoom with maxNativeZoom, but this clam can be adjusted according to server capability.

Platforms

All Android and iOS plateforms

Severity

Erroneous: Prevents normal functioning and causes errors in the console

ibrierley commented 2 hours ago

What happens if you set the maxZoom to 20 as well (or try 19 out of interest as well) ?

ChristopheLyonnaz commented 1 hour ago

Tile is loaded and displayed correctly. But it assumes you know what the max available zoom is, which may not be always true

ibrierley commented 1 hour ago

I'm a bit out of touch on the recovering parent tile code etc, but I suspect this would be quite fiddly to resolve. Lets say you want zoom 30, but only have access to max zoom 20, and are panning, so potentially have no historic higher zooms "cached"...I think you would need to keep lots of old tiles about which would hurt performance, and you may still not have cached tiles to use anyway, so would still hit the same problem...

It seems quite reasonable if adding a URL of a tile provider to know its valid tile range ?

ChristopheLyonnaz commented 1 hour ago

Yes, I agree, that would be easier. But in my specific use case, the tiles are loaded by the end user to have offline maps, and I don't know in advance what max zoom is available for a specific area when building the TileLayer.

However, I don't think it is necessary to 'cache' a lot of tiles. Just like when you manage a supported zoom, you request the missing tile to the server. Here also, if tile at zoom Z+1 is not available, it seems feasible to load tile at zoom Z.

ChristopheLyonnaz commented 45 minutes ago

To give more consistency to my latest comment, I did a small check. Let assume my server only provides tiles with max zoom as 10.

If a change _onTileUpdateEvent() in tile_layer.dart as follows:

  void _onTileUpdateEvent(TileUpdateEvent event) {
    final tileZoom = _clampToNativeZoom(event.zoom);
    ....

to

void _onTileUpdateEvent(TileUpdateEvent event) {
    final tileZoom = _clampToNativeZoom(event.zoom).clamp(widget.minNativeZoom, 10);
    ...

All is working nicely.

The goal is now to be able to build dynamically the '10' from the server capabilities (or in my case, from the available tile list in the displayed area). And either to delegate this to tile_layer, or to give a way for the TileLayer user to give this information in TileProvider or TileBuilder functions.

ibrierley commented 32 minutes ago

Hacky, but could you do something like the following before starting flutter_map to figure the max zoom from the given tiles..

pseudocode

sub figureMaxZoomAvailable { for x 9..30 { ok, error = getUrl ( "https://tile.openstreetmap.org/{$x}/1/1.png") if error, return x-1 }

ChristopheLyonnaz commented 16 minutes ago

Yes, I thought of such a solution, but unfortunalely, this is valid only if I have the same zoom level for all the area I want to cover.

If I have tiles up to zoom 10 over France, but up to 9 over US, I will start flutter_map with a TileLayer with maxNativeZoom = 9 if I visit the US, and start a new flutter_map if I visit France.

However, you gave me an idea. I may be able to create 1 TileLayer per area, and load them all in flutter_maps as children. Each TileLayer child would be defined with its maxNativeZoom and with its boundaries.

I'll keep you informed if this work around the issue.