gregallensworth / L.TileLayer.Cordova

Leaflet TileLayer subclass which caches to local filesystem, for Cordova/Phonegap
MIT License
87 stars 25 forks source link

Tiles are incorrectly caclulated based on bounds provided #17

Closed ryaa closed 7 years ago

ryaa commented 7 years ago

In my solution i need to store tiles for offline map support. I get bounds, invoke calculateXYZListFromBounds and download tiles using downloadXYZList. Everything is fine. However when I open the map in offline mode later, and leaflet tries to load tiles (from offline storage), some of the tiles are found and displayed on and some are not found For example, I see a lot of errors are below (/data/user/0/com.mobile.XXXXXXXXX/files/files/OfflineTileLayer//streetsLayer-17-30447-52707.png:1 GET file:///data/user/0/com.mobile.XXXXXX/files/files/OfflineTileLayer//streetsLayer-17-30447-52707.png net::ERR_FILE_NOT_FOUND).

I checked and the problem is that the urls generated by leaflet sometimes has different Y's For example for the above error, the first URL created by the lib is as below (there is not URL with ...52707.png

leaflet-tilelayer-cordova.js:281 streetsLayer-17-30446-52708.png missing. Fetching.
leaflet-tilelayer-cordova.js:200 Download https://api.tiles.mapbox.com/v4/mapbox.streets/17/30446/52708.png?access_to…czgxMSIsImEiOiJjaWphMXFjOTYwMDI1dmxrcTN6d3A4b2FjIn0.xFXXKg46zAe62uJHpUkGAw => file:///data/user/0/com.mobile.boss811/files/files/OfflineTileLayer//streetsLayer-17-30446-52708.png
tickets-list.ts:639 1 / 144 = 1%

Note the difference in y - the leaflet asks for 52707 and calculateXYZListFromBounds/downloadXYZList used 52708

I use leaflet 1.0.2

Btw, if i set to calculate/download tiles for wide zoom range (for example 1-20) everything seems to be working fine but it takes too much tiles to download

Thanks you very much

ryaa commented 7 years ago

Below is the method to download and save tiles for offline mode

    _downloadOfflineMap(ticketDetail: TicketDetail) {
        return new Promise<TicketDetail>((resolve: Function, reject: Function) => {

            if (this.isOnline && ticketDetail && ticketDetail.work_area) {

                let ZOOM_MIN = 16;
                let ZOOM_MAX = 20;
                let FOLDER_NAME = 'OfflineTileLayer';

                let streetsLayer = null;
                let satelliteLayer = null;
                let promiseForStreetsLayerInit = new Promise((resolve: Function, reject: Function) => {
                    try {
                        streetsLayer = L.tileLayerCordova('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', {
                            attribution: '© <a href="https://www.mapbox.com/map-feedback/">Mapbox</a> | © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
                            minZoon: ZOOM_MIN,
                            maxZoom: ZOOM_MAX,
                            id: 'mapbox.streets',
                            accessToken: MAPBOX_API_KEY,
                            // these are specific to L.TileLayer.Cordova and mostly specify where to store the tiles on disk
                            folder: FOLDER_NAME,
                            name: 'streetsLayer',
                            debug: true
                        }, () => {
                            resolve();
                        });
                    } catch (error) {
                        this.logger.error('TicketsList: error occurred while settings up downloading map tiles for offline support: ' + JSON.stringify(error));
                        let appError = new AppError(false, 'Downloading map tiles for offline support failed.');
                        reject(appError);
                    }
                });
                let promiseForSatelliteLayerInit = new Promise((resolve: Function, reject: Function) => {
                    try {
                        satelliteLayer = L.tileLayerCordova('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', {
                            attribution: '© <a href="https://www.mapbox.com/map-feedback/">Mapbox</a> | © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
                            minZoon: ZOOM_MIN,
                            maxZoom: ZOOM_MAX,
                            id: 'mapbox.streets-satellite',
                            accessToken: MAPBOX_API_KEY,
                            // these are specific to L.TileLayer.Cordova and mostly specify where to store the tiles on disk
                            folder: FOLDER_NAME,
                            name: 'satelliteLayer',
                            debug: true
                        }, () => {
                            resolve();
                        });
                    } catch (error) {
                        this.logger.error('TicketsList: error occurred while settings up downloading map tiles for offline support: ' + JSON.stringify(error));
                        let appError = new AppError(false, 'Downloading map tiles for offline support failed.');
                        reject(appError);
                    }
                });

                Promise.all([promiseForStreetsLayerInit, promiseForSatelliteLayerInit])
                    .then(() => {
                        let geoJSONLayer = L.geoJSON(ticketDetail.work_area);
                        let _bounds = geoJSONLayer.getBounds();

                        // calculate tiles to cache based on the bounds and going down to a stated range of zoom levels
                        let tile_list = streetsLayer.calculateXYZListFromBounds(_bounds, ZOOM_MIN, ZOOM_MAX);
                        //let center = _bounds.getCenter();
                        //let tile_list = streetsLayer.calculateXYZListFromPyramid(center.lat, center.lng, ZOOM_MIN, ZOOM_MAX);
                        let promiseForStreetsLayerDownload = new Promise((resolve: Function, reject: Function) => {
                            streetsLayer.downloadXYZList(
                                // 1st param: a list of XYZ objects indicating tiles to download
                                tile_list,
                                // 2nd param: overwrite existing tiles on disk?
                                // if no then a tile already on disk will be kept, which can be a big time saver
                                false, //true,
                                // 3rd param: progress callback
                                // receives the number of tiles downloaded and the number of tiles total
                                // caller can calculate a percentage, update progress bar, etc.
                                function (done, total) {
                                    var percent = Math.round(100 * done / total);
                                    console.log(done + " / " + total + " = " + percent + "%");
                                },
                                // 4th param: complete callback
                                // no parameters are given, but we know we're done!
                                function () {
                                    // for this demo, on success we use another L.TileLayer.Cordova feature and show the disk usage!
                                    streetsLayer.getDiskUsage(function (filecount, bytes) {
                                        var kilobytes = Math.round(bytes / 1024);
                                        console.log("Done " + filecount + " files " + kilobytes + " kB");
                                    });
                                    resolve();
                                },
                                // 5th param: error callback
                                // parameter is the error message string
                                function (error) {
                                    console.error("Failed; Error code: " + error.code);
                                    reject();
                                }
                            );
                        });
                        let promiseForSatelliteLayerDownload = new Promise((resolve: Function, reject: Function) => {
                            satelliteLayer.downloadXYZList(
                                // 1st param: a list of XYZ objects indicating tiles to download
                                tile_list,
                                // 2nd param: overwrite existing tiles on disk?
                                // if no then a tile already on disk will be kept, which can be a big time saver
                                false, //true,
                                // 3rd param: progress callback
                                // receives the number of tiles downloaded and the number of tiles total
                                // caller can calculate a percentage, update progress bar, etc.
                                function (done, total) {
                                    var percent = Math.round(100 * done / total);
                                    console.log(done + " / " + total + " = " + percent + "%");
                                },
                                // 4th param: complete callback
                                // no parameters are given, but we know we're done!
                                function () {
                                    // for this demo, on success we use another L.TileLayer.Cordova feature and show the disk usage!
                                    streetsLayer.getDiskUsage(function (filecount, bytes) {
                                        var kilobytes = Math.round(bytes / 1024);
                                        console.log("Done " + filecount + " files " + kilobytes + " kB");
                                    });
                                    resolve();
                                },
                                // 5th param: error callback
                                // parameter is the error message string
                                function (error) {
                                    console.error("Failed; Error code: " + error.code);
                                    reject(error);
                                }
                            );
                        });

                        Promise.all([promiseForStreetsLayerDownload, promiseForSatelliteLayerDownload])
                            .then(() => {
                                resolve(ticketDetail);
                            })
                            .catch((error: any) => {
                                this.logger.error('TicketsList: error occurred while downloading map tiles for offline support: ' + JSON.stringify(error));
                                let appError = new AppError(false, 'Downloading map tiles for offline support failed.');
                                reject(appError);
                            });
                    })
                    .catch((appError: AppError) => {
                        reject(appError);
                    });

            } else {
                resolve(ticketDetail);
            }

        });
    }

and here is the code to load the map on a different page

  loadMap() {

    if (this.item && this.item.work_area) {

      let ZOOM_MIN = 16;
      let ZOOM_MAX = 20;
      let FOLDER_NAME = 'OfflineTileLayer';

      this.map = L.map("map", {
        minZoom: ZOOM_MIN,
        maxZoom: ZOOM_MAX,
        zoomControl: false
      });

      let streetsLayer = null;
      let satelliteLayer = null;
      let promiseForStreetsLayerInit = new Promise((resolve: Function, reject: Function) => {
        try {
          streetsLayer = L.tileLayerCordova('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', {
            attribution: '© <a href="https://www.mapbox.com/map-feedback/">Mapbox</a> | © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
            minZoon: ZOOM_MIN,
            maxZoom: ZOOM_MAX,
            id: 'mapbox.streets',
            accessToken: MAPBOX_API_KEY,
            // these are specific to L.TileLayer.Cordova and mostly specify where to store the tiles on disk
            folder: FOLDER_NAME,
            name: 'streetsLayer',
            debug: true
          }, () => {
            resolve();
          });
        } catch (error) {
          this.logger.error('TicketsList: error occurred while settings up downloading map tiles for offline support: ' + JSON.stringify(error));
          let appError = new AppError(false, 'Downloading map tiles for offline support failed.');
          reject(appError);
        }
      });
      let promiseForSatelliteLayerInit = new Promise((resolve: Function, reject: Function) => {
        try {
          satelliteLayer = L.tileLayerCordova('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', {
            attribution: '© <a href="https://www.mapbox.com/map-feedback/">Mapbox</a> | © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
            minZoon: ZOOM_MIN,
            maxZoom: ZOOM_MAX,
            id: 'mapbox.streets-satellite',
            accessToken: MAPBOX_API_KEY,
            // these are specific to L.TileLayer.Cordova and mostly specify where to store the tiles on disk
            folder: FOLDER_NAME,
            name: 'satelliteLayer',
            debug: true
          }, () => {
            resolve();
          });
        } catch (error) {
          this.logger.error('TicketsList: error occurred while settings up downloading map tiles for offline support: ' + JSON.stringify(error));
          let appError = new AppError(false, 'Downloading map tiles for offline support failed.');
          reject(appError);
        }
      });

      Promise.all([promiseForStreetsLayerInit, promiseForSatelliteLayerInit])
        .then(() => {

          if (this.isOnline) {
            streetsLayer.goOnline();
            satelliteLayer.goOnline();
          } else {
            streetsLayer.goOffline();
            satelliteLayer.goOffline();
          }

          streetsLayer.addTo(this.map);

          let layerControl = L.control.layers({}, {});
          layerControl.addBaseLayer(streetsLayer, 'Streets');
          layerControl.addBaseLayer(satelliteLayer, 'Satellite');
          this.map.layerControl = layerControl.addTo(this.map);

          let geoJSONLayer = L.geoJSON(this.item.work_area);
          let _bounds = geoJSONLayer.getBounds();

          if (this.item.work_area.type == 'Point') {
            this.map.fitBounds(_bounds);
            geoJSONLayer.addTo(this.map);
          } else {
            let layerStyle = { fillColor: '#2190c4', color: '#25A0DA', weight: 3 };
            geoJSONLayer.setStyle(() => { return layerStyle });

            this.map.fitBounds(_bounds);
            geoJSONLayer.addTo(this.map);
          }

          if (!this.isOnline) {
            this.map.dragging.disable();
            this.map.touchZoom.disable();
            this.map.doubleClickZoom.disable();
            this.map.scrollWheelZoom.disable();
            this.map.boxZoom.disable();
          }

    }
  }
gregallensworth commented 7 years ago

Interesting, I wonder if there's some rounding error someplace.

Can you tell me, the Lat-Lng bounds you're using here? I'll want to set up a map of the same bounding box and compare some of the tile-numbering outputs.

ryaa commented 7 years ago

To get bounds I use let geoJSONLayer = L.geoJSON(this.item.work_area); (this is in my code above)

For example I can reproduce the problem for the below work_area

{
  "type": "Polygon",
  "coordinates": [
    [
      [
        -96.372664221291,
        33.201604114837
      ],
      [
        -96.375364221291,
        33.201864114837
      ],
      [
        -96.375824221291,
        33.199524114837
      ],
      [
        -96.372964221291,
        33.199304114837
      ],
      [
        -96.372664221291,
        33.201604114837
      ]
    ]
  ]
}

When i save tiles for offline mode using the above _downloadOfflineMap method and then view the map in offline mode I get the below (note missing files) offline

The online mode shows fine since the same files get downloaded from the server online

If this is easier, I can provide cordova android application built in debug to see the problem

Thank you very much for you feedback

gregallensworth commented 7 years ago

Sitting down with one of my own applications here, I am not getting the same problem when I download from that same area. This other one (a MobileMapStarter) downloads the tiles and does not show any Not Found errors.

You said that you could provide the functioning app with debugging enabled? That would be helpful, if you are able to provide it.

ryaa commented 7 years ago

Hi, thank you for prompt feedback. The cordova app for Android showing the problem with debug enabled can be found at https://www.dropbox.com/sh/fxek905zxjlo6qy/AAARgEo4KcPUUEvBdkX8U8gsa?dl=0 There are several installers for different platforms.

Steps to reproduce the problem: 1) install the app 2) login as (you may need to skip first time launch tutorial) domain: first username: mobiledev@example.com password: 123123123 3) when on the main page (tickets list) click search and enter 1654702483 to find the ticket with this number 4) for this ticket card, please click on the right bottom cloud icon to add this ticket to offline mode This will save the ticket and all the related details into the offline storage. This includes saving the map for this ticket (more on this later) 5) when save is successfully complete (no errors shows and the cloud icon turns blue) click on the ticket to open it 6) in the ticket details click on the last/third tab to see the map with a polygon displayed (this is the ticket work area as I described earlier) Note that it displays fine in online mode 7) return to ticket list 8) disable the network (I usually turn off wifi on my device) 9) open the same ticket 1654702483 details 10) in the ticket details click on the last/third tab to see the map with a polygon displayed Note that the map shown has some tiles missing

Now, if you connect your device to the dev machine, open Chrome, enter chrome://inspect and start debugging session you will see these not found error for the missing tiles

Here are some tech details (see attached screenshot as well): 1) map tiles are saved for offline mode in /pages/ticket-list/ticket-list.ts in _downloadOfflineMap(ticketDetail: TicketDetail) method 2) map is loaded (in online or offline mode) in /pages/ticket-detail/ticket-detail-tab3.ts in loadMap() method Both of these methods uses you library.

Note that this problem: a) reproducible for many tickets. The one above is just one of the samples b) i tried to change min and max zoom and the problem is gone only when min is very small and max is 20. However this triggers a very large download For min/max zoom combinations 17-19, 16-20 etc the problem is reproducible

Please let me know if you have any questions. If it is easier for you I'm available to support in skype alex_ryaa

Thank you very much

online

gregallensworth commented 7 years ago

I'm finally getting a chance to sit down with your app, to see what I can figure out. Also, I tried contacting you via Skype, but have not received a reply.

I'm guessing it's a rounding error only significant at very-close ranges and very-small areas. Some of the JSON shapes are smaller than a single, which was not a condition I had tested previously. I'll let you know what I find.

gregallensworth commented 7 years ago

I'm not sure that there really is an error here.

The shape above, does not extend into the tile above (17-30446-52707) so it's correct that this tile was not downloaded. The latitude range seems to correspond to 52708 and 52709, but not 52707.

 function long2tile(lon,zoom) { return (Math.floor((lon+180)/360*Math.pow(2,zoom))); }

 function lat2tile(lat,zoom)  { return (Math.floor((1-Math.log(Math.tan(lat*Math.PI/180) + 
1/Math.cos(lat*Math.PI/180))/Math.PI)/2 *Math.pow(2,zoom))); }

lat2tile(33.199304, 17)     52709
lat2tile(33.2018641, 17)     52708

Reference: http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#ECMAScript_.28JavaScript.2FActionScript.2C_etc..29

As such, I don't think this is a bug at all. L.TileLayer.Cordova does not attempt to download a buffered area around your extent, but when you pan the map Leaflet would still try to load 52707 and fail since it's not downloaded.

I further confirmed this in the inspector (GapDebug) and the screenshot shows the tiles that are currently in DOM. Specifically of note, is that the 52707 tiles are indeed further north than the extent of your shape.

screen shot 2017-03-21 at 6 00 25 pm

My advice for an immediate workaround to get a surrounding area, is that you pad() the _bounds to include a bit of surrounding area.

// see leaflet LatLngBounds pad() method
var _bounds = geoJSONLayer.getBounds().pad(0.25);

If I am misunderstanding the problem, please let me know. But given that L.TileLayer.Cordova really does restrict to only the bounds given, it does seem proper that 52707 would be excluded here and that northern tile should be absent from the offline cache.

ryaa commented 7 years ago

Greg,

Thank you very much for your help. I was under assumption that if i download tiles for specific bounds for offline these will cover the entire area when the map shows in offline mode. Anyway, i added the suggested workaround and it seems to be working. I will need to test more to see if 0.25 enough or it needs to be increased to cover all scenarios.

There is one more issue that I found and I'm not sure if this is bug in this library or leaflet itself. When viewing the map saved for offline mode and changing orientation the map shows with tiles missing and it seems that it does not even try to fetch these tiles - see attached This limitation exists for both online and offline mode on iOS and for offline mode on Android so this could not be this library bug. Please let me know your thoughts and i can create another ticket if necessary

screenshot_20170322-204041

gregallensworth commented 7 years ago

"changing orientation the map shows with tiles missing and it seems that it does not even try to fetch these tiles - see attached"

You need to detect resize event on the map DIV, and trigger the L.Map's invalidateSize() function. That's not related to Cordova nor Android specifically, as much "just a Leaflet thing"

ryaa commented 7 years ago

This is exactly what i did however it did not fix the problem. Anyway, this is a different problem, not related to the library. Thank you very much!