MazeMap / Leaflet.TileLayer.PouchDBCached

A Leaflet tile layer which caches into PouchDB for offline use
MIT License
207 stars 68 forks source link

improvement proposal #31

Open gugurete opened 7 years ago

gugurete commented 7 years ago

By using xhr requests instead of canvas rendering you could have a major performance improvement. I do not have time for a proper pull request, but here is my take on pouchdb caching

import L from 'leaflet' import PouchDB from '/imports/ui/components/pouchdb' import qwest from 'qwest' import logger from '../../logger'

L.TileLayer.addInitHook(function () { if (this.options.cache) { this.pouch = new PouchDB(tiles-${this.options.cacheName}, { size: 1000, auto_compaction: true, revs_limit: 1 }) logger.info(pouchdb: ${this.pouch.name}, adapter: ${this.pouch.adapter}) } })

// Whether to use a PouchDB cache on this tile layer, or not L.TileLayer.prototype.options.cache = false;

// Maximum age of the cache, in milliseconds L.TileLayer.prototype.options.cacheMaxAge = 24 3600 1000;

L.TileLayer.include({

// Overwrites L.TileLayer.prototype.createTile createTile(coords, done) { const url = this.getTileUrl(coords); const tile = document.createElement('img'); if (this.options.crossOrigin) { tile.crossOrigin = ''; } tile.onerror = () => { logger.error(bom.leaf load failed: ${tile.src}) } if (this.options.cache) { this.pouch.get(url, {attachments: true, binary: true, revs_info: true}, this.onCacheLookup(tile, url, done)); } else { // Fall back to standard behaviour tile.onload = L.bind(this._tileOnLoad, this, done, tile); tile.src = url; } return tile; },

// Returns a callback (closure over tile/key/originalSrc) to be run when the DB backend is finished with a fetch operation. onCacheLookup(tile, tileUrl, done) { return function(err, data) { if (data) { this.fire('tilecachehit', {tile, url: tileUrl}); if ((Date.now() > (data.timestamp + this.options.cacheMaxAge))) { // old tile, refresh it tile.qwest = null this.readAndSaveTile(tile, tileUrl, data._revs_info[0].rev, done) } else { // tile from cache tile.onload = L.bind(this._tileOnLoad, this, done, tile); tile.src = URL.createObjectURL(data._attachments.img.data); } } else { this.fire('tilecachemiss', {tile, url: tileUrl}); this.readAndSaveTile(tile, tileUrl, null, done) } }.bind(this); },

readAndSaveTile(tile, tileUrl, existingRevision, done) { if (tile.qwest) { return } let headers = {} if (tileUrl.indexOf('cors-anywhere') >= 0) { headers = { 'X-Requested-With': 'XMLHttpRequest' } } tile.qwest = qwest.get(tileUrl, null, {headers, cache: true, responseType: 'blob'}).then(res => { this.saveTile(res.response, tileUrl, existingRevision, done) tile.src = URL.createObjectURL(res.response); }).catch(error => { logger.error('qwest error', error.message); }) },

// Overwrite L.TileLayer.prototype._abortLoading _abortLoading() { let i let tile for (i in this._tiles) { if (this._tiles[i].coords.z !== this._tileZoom) { tile = this._tiles[i].el; tile.onload = L.Util.falseFn; tile.onerror = L.Util.falseFn; if (!tile.complete) { tile.src = L.Util.emptyImageUrl; L.DomUtil.remove(tile); } if (tile.qwest) { tile.qwest.abort() } } } },

// Returns an event handler (closure over DB key), which runs when the tile (which is an ) is ready. // The handler will delete the document from pouchDB if an existing revision is passed. // This will keep just the latest valid copy of the image in the cache. saveTile(tileBlob, tileUrl, existingRevision, done) { const doc = { _id: tileUrl, _attachments: { img: { content_type: 'image/jpeg', data: tileBlob } }, timestamp: Date.now() } if (existingRevision) { this.pouch.remove(tileUrl, existingRevision, () => { this.pouchPut(doc) }) } else { this.pouchPut(doc) } if (done) { done() } },

pouchPut(doc) { this.pouch.put(doc).then(res => { logger.debug('tile cached', res) }).catch(err => { if (err.status === 409) { // document update conflicts may be caused by XHR flooding, // but we can safely ignore them since we keep only one version anyway and we need no db sync logger.debug(err) } else { logger.error(err) } this.fire('tilecacheerror', {tile: doc._id, error: err}) }) }

});

albfan commented 1 year ago

Turning this into a diff to see the difference: I don't see the problem to create a PR

diff --git 1/L.TileLayer.PouchDBCached.js 2/new
index c0d7d5c..25a96d5 100644
--- 1/L.TileLayer.PouchDBCached.js
+++ 2/titi
@@ -1,280 +1,140 @@
-
-
 L.TileLayer.addInitHook(function () {
-
-   if (!this.options.useCache) {
-       this._db     = null;
-       this._canvas = null;
-       return;
+if (this.options.cache) {
+this.pouch = new PouchDB(tiles-${this.options.cacheName}, {
+size: 1000,
+auto_compaction: true,
+revs_limit: 1
+})
+logger.info(pouchdb: ${this.pouch.name}, adapter: ${this.pouch.adapter})
 }
+})

-   var dbName = this.options.dbName || 'offline-tiles';
-   if (this.options.dbOptions) {
-       this._db = new PouchDB(dbName, this.options.dbOptions);
-   } else {
-       this._db = new PouchDB(dbName);
-   }
-   this._canvas = document.createElement('canvas');
-
-   if (!(this._canvas.getContext && this._canvas.getContext('2d'))) {
-       // HTML5 canvas is needed to pack the tiles as base64 data. If
-       //   the browser doesn't support canvas, the code will forcefully
-       //   skip caching the tiles.
-       this._canvas = null;
-   }
-});
-
-// 🍂namespace TileLayer
-// 🍂section PouchDB tile caching options
-// 🍂option useCache: Boolean = false
 // Whether to use a PouchDB cache on this tile layer, or not
-L.TileLayer.prototype.options.useCache     = false;
+L.TileLayer.prototype.options.cache = false;

-// 🍂option saveToCache: Boolean = true
-// When caching is enabled, whether to save new tiles to the cache or not
-L.TileLayer.prototype.options.saveToCache  = true;
-
-// 🍂option useOnlyCache: Boolean = false
-// When caching is enabled, whether to request new tiles from the network or not
-L.TileLayer.prototype.options.useOnlyCache = false;
-
-// 🍂option useCache: String = 'image/png'
-// The image format to be used when saving the tile images in the cache
-L.TileLayer.prototype.options.cacheFormat = 'image/png';
-
-// 🍂option cacheMaxAge: Number = 24*3600*1000
 // Maximum age of the cache, in milliseconds
 L.TileLayer.prototype.options.cacheMaxAge = 24 * 3600 * 1000;

-
 L.TileLayer.include({

 // Overwrites L.TileLayer.prototype.createTile
-   createTile: function(coords, done) {
-       var tile = document.createElement('img');
-
-       tile.onerror = L.bind(this._tileOnError, this, done, tile);
-
+createTile(coords, done) {
+const url = this.getTileUrl(coords);
+const tile = document.createElement('img');
 if (this.options.crossOrigin) {
 tile.crossOrigin = '';
 }
-
-       /*
-        Alt tag is *set to empty string to keep screen readers from reading URL and for compliance reasons
-        http://www.w3.org/TR/WCAG20-TECHS/H67
-        */
-       tile.alt = '';
-
-       var tileUrl = this.getTileUrl(coords);
-
-       if (this.options.useCache && this._canvas) {
-           this._db.get(tileUrl, {revs_info: true}, this._onCacheLookup(tile, tileUrl, done));
+tile.onerror = () => {
+logger.error(bom.leaf load failed: ${tile.src})
+}
+if (this.options.cache) {
+this.pouch.get(url, {attachments: true, binary: true, revs_info: true}, this.onCacheLookup(tile, url, done));
 } else {
 // Fall back to standard behaviour
 tile.onload = L.bind(this._tileOnLoad, this, done, tile);
+tile.src = url;
 }
-
-       tile.src = tileUrl;
 return tile;
 },

-   // Returns a callback (closure over tile/key/originalSrc) to be run when the DB
-   //   backend is finished with a fetch operation.
-   _onCacheLookup: function(tile, tileUrl, done) {
+// Returns a callback (closure over tile/key/originalSrc) to be run when the DB backend is finished with a fetch operation.
+onCacheLookup(tile, tileUrl, done) {
 return function(err, data) {
 if (data) {
-               this.fire('tilecachehit', {
-                   tile: tile,
-                   url: tileUrl
-               });
-               if (Date.now() > data.timestamp + this.options.cacheMaxAge && !this.options.useOnlyCache) {
-                   // Tile is too old, try to refresh it
-                   //console.log('Tile is too old: ', tileUrl);
-
-                   if (this.options.saveToCache) {
-                       tile.onload = L.bind(this._saveTile, this, tile, tileUrl, data._revs_info[0].rev, done);
-                   }
-                   tile.crossOrigin = 'Anonymous';
-                   tile.src = tileUrl;
-                   tile.onerror = function(ev) {
-                       // If the tile is too old but couldn't be fetched from the network,
-                       //   serve the one still in cache.
-                       this.src = data.dataUrl;
-                   }
+this.fire('tilecachehit', {tile, url: tileUrl});
+if ((Date.now() > (data.timestamp + this.options.cacheMaxAge))) {
+// old tile, refresh it
+tile.qwest = null
+this.readAndSaveTile(tile, tileUrl, data._revs_info[0].rev, done)
 } else {
-                   // Serve tile from cached data
-                   //console.log('Tile is cached: ', tileUrl);
+// tile from cache
 tile.onload = L.bind(this._tileOnLoad, this, done, tile);
-                   tile.src = data.dataUrl;    // data.dataUrl is already a base64-encoded PNG image.
+tile.src = URL.createObjectURL(data._attachments.img.data);
 }
 } else {
-               this.fire('tilecachemiss', {
-                   tile: tile,
-                   url: tileUrl
-               });
-               if (this.options.useOnlyCache) {
-                   // Offline, not cached
-//                     console.log('Tile not in cache', tileUrl);
-                   tile.onload = L.Util.falseFn;
-                   tile.src = L.Util.emptyImageUrl;
-               } else {
-                   //Online, not cached, request the tile normally
-//                     console.log('Requesting tile normally', tileUrl);
-                   if (this.options.saveToCache) {
-                       tile.onload = L.bind(this._saveTile, this, tile, tileUrl, null, done);
-                   } else {
-                       tile.onload = L.bind(this._tileOnLoad, this, done, tile);
-                   }
-                   tile.crossOrigin = 'Anonymous';
-                   tile.src = tileUrl;
-               }
+this.fire('tilecachemiss', {tile, url: tileUrl});
+this.readAndSaveTile(tile, tileUrl, null, done)
 }
 }.bind(this);
 },

-   // Returns an event handler (closure over DB key), which runs
-   //   when the tile (which is an <img>) is ready.
-   // The handler will delete the document from pouchDB if an existing revision is passed.
-   //   This will keep just the latest valid copy of the image in the cache.
-   _saveTile: function(tile, tileUrl, existingRevision, done) {
-       if (this._canvas === null) return;
-       this._canvas.width  = tile.naturalWidth  || tile.width;
-       this._canvas.height = tile.naturalHeight || tile.height;
-
-       var context = this._canvas.getContext('2d');
-       context.drawImage(tile, 0, 0);
-
-       var dataUrl;
-       try {
-           dataUrl = this._canvas.toDataURL(this.options.cacheFormat);
-       } catch(err) {
-           this.fire('tilecacheerror', { tile: tile, error: err });
-           return done();
+readAndSaveTile(tile, tileUrl, existingRevision, done) {
+if (tile.qwest) {
+return
 }
-
-       var doc = {_id: tileUrl, dataUrl: dataUrl, timestamp: Date.now()};
-       if (existingRevision) {
-         this._db.get(tileUrl).then(function(doc) {
-             return this._db.put({
-                 _id: doc._id,
-                 _rev: doc._rev,
-                 dataUrl: dataUrl,
-                 timestamp: Date.now()
-             });
-         }.bind(this)).then(function(response) {
-           //console.log('_saveTile update: ', response);
-         });
-       } else {
-         this._db.put(doc).then( function(doc) {
-           //console.log('_saveTile insert: ', doc);
-         });
+let headers = {}
+if (tileUrl.indexOf('cors-anywhere') >= 0) {
+headers = {
+'X-Requested-With': 'XMLHttpRequest'
 }
-
-       if (done) {
-         done();
 }
+tile.qwest = qwest.get(tileUrl, null, {headers, cache: true, responseType: 'blob'}).then(res => {
+this.saveTile(res.response, tileUrl, existingRevision, done)
+tile.src = URL.createObjectURL(res.response);
+}).catch(error => {
+logger.error('qwest error', error.message);
+})
 },

-   // 🍂section PouchDB tile caching options
-   // 🍂method seed(bbox: LatLngBounds, minZoom: Number, maxZoom: Number): this
-   // Starts seeding the cache given a bounding box and the minimum/maximum zoom levels
-   // Use with care! This can spawn thousands of requests and flood tileservers!
-   seed: function(bbox, minZoom, maxZoom) {
-       if (!this.options.useCache) return;
-       if (minZoom > maxZoom) return;
-       if (!this._map) return;
-
-       var queue = [];
-
-       for (var z = minZoom; z<=maxZoom; z++) {
-
-           var northEastPoint = this._map.project(bbox.getNorthEast(),z);
-           var southWestPoint = this._map.project(bbox.getSouthWest(),z);
-
-           // Calculate tile indexes as per L.TileLayer._update and
-           //   L.TileLayer._addTilesFromCenterOut
-           var tileSize = this.getTileSize();
-           var tileBounds = L.bounds(
-               L.point(Math.floor(northEastPoint.x / tileSize.x), Math.floor(northEastPoint.y / tileSize.y)),
-               L.point(Math.floor(southWestPoint.x / tileSize.x), Math.floor(southWestPoint.y / tileSize.y)));
-
-           for (var j = tileBounds.min.y; j <= tileBounds.max.y; j++) {
-               for (var i = tileBounds.min.x; i <= tileBounds.max.x; i++) {
-                   point = new L.Point(i, j);
-                   point.z = z;
-                   queue.push(this._getTileUrl(point));
+// Overwrite L.TileLayer.prototype._abortLoading
+_abortLoading() {
+let i
+let tile
+for (i in this._tiles) {
+if (this._tiles[i].coords.z !== this._tileZoom) {
+tile = this._tiles[i].el;
+tile.onload = L.Util.falseFn;
+tile.onerror = L.Util.falseFn;
+if (!tile.complete) {
+tile.src = L.Util.emptyImageUrl;
+L.DomUtil.remove(tile);
 }
+if (tile.qwest) {
+tile.qwest.abort()
 }
 }
-
-       var seedData = {
-           bbox: bbox,
-           minZoom: minZoom,
-           maxZoom: maxZoom,
-           queueLength: queue.length
 }
-       this.fire('seedstart', seedData);
-       var tile = this._createTile();
-       tile._layer = this;
-       this._seedOneTile(tile, queue, seedData);
-       return this;
 },

-   _createTile: function () {
-       return new Image();
-   },
-
-   // Modified L.TileLayer.getTileUrl, this will use the zoom given by the parameter coords
-   //  instead of the maps current zoomlevel.
-   _getTileUrl: function (coords) {
-       var zoom = coords.z;
-       if (this.options.zoomReverse) {
-           zoom = this.options.maxZoom - zoom;
+// Returns an event handler (closure over DB key), which runs when the tile (which is an ) is ready.
+// The handler will delete the document from pouchDB if an existing revision is passed.
+// This will keep just the latest valid copy of the image in the cache.
+saveTile(tileBlob, tileUrl, existingRevision, done) {
+const doc = {
+_id: tileUrl,
+_attachments: {
+img: {
+content_type: 'image/jpeg',
+data: tileBlob
 }
-       zoom += this.options.zoomOffset;
-       return L.Util.template(this._url, L.extend({
-           r: this.options.detectRetina && L.Browser.retina && this.options.maxZoom > 0 ? '@2x' : '',
-           s: this._getSubdomain(coords),
-           x: coords.x,
-           y: this.options.tms ? this._globalTileRange.max.y - coords.y : coords.y,
-           z: this.options.maxNativeZoom ? Math.min(zoom, this.options.maxNativeZoom) : zoom
-       }, this.options));
 },
-
-   // Uses a defined tile to eat through one item in the queue and
-   //   asynchronously recursively call itself when the tile has
-   //   finished loading.
-   _seedOneTile: function(tile, remaining, seedData) {
-       if (!remaining.length) {
-           this.fire('seedend', seedData);
-           return;
+timestamp: Date.now()
 }
-       this.fire('seedprogress', {
-           bbox:    seedData.bbox,
-           minZoom: seedData.minZoom,
-           maxZoom: seedData.maxZoom,
-           queueLength: seedData.queueLength,
-           remainingLength: remaining.length
-       });
-
-       var url = remaining.pop();
+if (existingRevision) {
+this.pouch.remove(tileUrl, existingRevision, () => {
+this.pouchPut(doc)
+})
+} else {
+this.pouchPut(doc)
+}
+if (done) {
+done()
+}
+},

-       this._db.get(url, function(err, data) {
-           if (!data) {
-               tile.onload = function(e) {
-                   this._saveTile(tile, url, null);
-                   this._seedOneTile(tile, remaining, seedData);
-               }.bind(this);
-               tile.onerror = function(e) {
-                   // Could not load tile, let's continue anyways.
-                   this._seedOneTile(tile, remaining, seedData);
-               }.bind(this);
-               tile.crossOrigin = 'Anonymous';
-               tile.src = url;
+pouchPut(doc) {
+this.pouch.put(doc).then(res => {
+logger.debug('tile cached', res)
+}).catch(err => {
+if (err.status === 409) {
+// document update conflicts may be caused by XHR flooding,
+// but we can safely ignore them since we keep only one version anyway and we need no db sync
+logger.debug(err)
 } else {
-               this._seedOneTile(tile, remaining, seedData);
+logger.error(err)
 }
-       }.bind(this));
+this.fire('tilecacheerror', {tile: doc._id, error: err})
+})
 }

 });