leaflet-nogap.js (9.6 KB)
1 // @class TileLayer 2 3 L.TileLayer.mergeOptions({ 4 // @option keepBuffer 5 // The amount of tiles outside the visible map area to be kept in the stitched 6 // `TileLayer`. 7 8 // @option dumpToCanvas: Boolean = true 9 // Whether to dump loaded tiles to a `<canvas>` to prevent some rendering 10 // artifacts. (Disabled by default in IE) 11 dumpToCanvas: L.Browser.canvas && !L.Browser.ie, 12 }); 13 14 L.TileLayer.include({ 15 // Backing-store density. The canvas is sized in CSS px (so Leaflet's positioning is 16 // unchanged) but its backing store is ratio× larger and the context is pre-scaled by 17 // ratio, so high-res source tiles keep their detail instead of being flattened to 1x. 18 // Full DPR for sharpness; the crash from large canvases is bounded by a small keepBuffer 19 // (see the tileLayer options in script.js) rather than by capping ratio. 20 _canvasRatio: function() { 21 return Math.max(1, Math.round(window.devicePixelRatio || 1)); 22 }, 23 24 _onUpdateLevel: function(z) { 25 if (this.options.dumpToCanvas) { 26 // base Leaflet calls this with only z; mirror its own zIndex math off _tileZoom 27 // (the original plugin read an undefined `zoom` arg here, yielding NaN). Correct 28 // z-index stacking lets a new level's canvas sit over the old until it's pruned, 29 // so no opacity tricks are needed to avoid a pop on zoom. 30 var curZoom = this._tileZoom; 31 this._levels[z].canvas.style.zIndex = 32 this.options.maxZoom - Math.abs(curZoom - z); 33 } 34 }, 35 36 _onRemoveLevel: function(z) { 37 if (this.options.dumpToCanvas) { 38 L.DomUtil.remove(this._levels[z].canvas); 39 } 40 }, 41 42 _onCreateLevel: function(level) { 43 if (this.options.dumpToCanvas) { 44 level.canvas = L.DomUtil.create( 45 "canvas", 46 "leaflet-tile-container leaflet-zoom-animated", 47 this._container 48 ); 49 level.canvas.style.pointerEvents = "none"; 50 level.ctx = level.canvas.getContext("2d"); 51 this._resetCanvasSize(level); 52 } 53 }, 54 55 _removeTile: function(key) { 56 if (this.options.dumpToCanvas) { 57 var tile = this._tiles[key]; 58 var level = this._levels[tile.coords.z]; 59 var tileSize = this.getTileSize(); 60 61 if (level) { 62 // Where in the canvas should this tile go? 63 var offset = L.point(tile.coords.x, tile.coords.y) 64 .subtract(level.canvasRange.min) 65 .scaleBy(this.getTileSize()); 66 67 level.ctx.clearRect(offset.x, offset.y, tileSize.x, tileSize.y); 68 } 69 } 70 71 L.GridLayer.prototype._removeTile.call(this, key); 72 }, 73 74 _resetCanvasSize: function(level) { 75 var buff = this.options.keepBuffer, 76 pixelBounds = this._getTiledPixelBounds(this._map.getCenter()), 77 tileRange = this._pxBoundsToTileRange(pixelBounds), 78 tileSize = this.getTileSize(); 79 80 tileRange.min = tileRange.min.subtract([buff, buff]); // This adds the no-prune buffer 81 tileRange.max = tileRange.max.add([buff + 1, buff + 1]); 82 83 var pixelRange = L.bounds( 84 tileRange.min.scaleBy(tileSize), 85 tileRange.max.add([1, 1]).scaleBy(tileSize) // This prevents an off-by-one when checking if tiles are inside 86 ), 87 mustRepositionCanvas = false, 88 neededSize = pixelRange.max.subtract(pixelRange.min); 89 90 // Resize the canvas, if needed, and only to make it bigger. CSS size stays in CSS px; 91 // backing store is ratio× larger so high-DPR tiles keep their detail (see _canvasRatio). 92 var ratio = this._canvasRatio(); 93 if ( 94 neededSize.x > level.canvas.width / ratio || 95 neededSize.y > level.canvas.height / ratio 96 ) { 97 // Resizing canvases erases the currently drawn content, I'm afraid. 98 // To keep it, dump the pixels to another canvas, then display it on 99 // top. This could be done with getImageData/putImageData, but that 100 // would break for tainted canvases (in non-CORS tilesets). Copy at 101 // backing-store resolution (1:1, no ctx scale) to avoid resampling. 102 var oldSize = { x: level.canvas.width, y: level.canvas.height }; 103 // console.info('Resizing canvas from ', oldSize, 'to ', neededSize); 104 105 var tmpCanvas = L.DomUtil.create("canvas"); 106 tmpCanvas.width = oldSize.x; 107 tmpCanvas.height = oldSize.y; 108 tmpCanvas.getContext("2d").drawImage(level.canvas, 0, 0); 109 // var data = level.ctx.getImageData(0, 0, oldSize.x, oldSize.y); 110 111 level.canvas.style.width = neededSize.x + "px"; 112 level.canvas.style.height = neededSize.y + "px"; 113 level.canvas.width = neededSize.x * ratio; 114 level.canvas.height = neededSize.y * ratio; 115 // drawImage at native backing-store coords, so reset any prior ctx scale first 116 level.ctx.setTransform(1, 0, 0, 1, 0, 0); 117 level.ctx.drawImage(tmpCanvas, 0, 0); 118 // every other draw in this layer works in CSS px; pre-scale so it lands on the 119 // ratio× backing store 120 level.ctx.setTransform(ratio, 0, 0, ratio, 0, 0); 121 // level.ctx.putImageData(data, 0, 0, 0, 0, oldSize.x, oldSize.y); 122 } 123 124 // Translate the canvas contents if it's moved around. This is a whole-canvas 125 // backing-store copy, so run it at native scale (offset in backing-store px) and 126 // restore the CSS-px ctx scale afterward. 127 if (level.canvasRange) { 128 var offset = level.canvasRange.min 129 .subtract(tileRange.min) 130 .scaleBy(this.getTileSize()) 131 .multiplyBy(ratio); 132 133 // console.info('Offsetting by ', offset); 134 135 level.ctx.setTransform(1, 0, 0, 1, 0, 0); 136 137 if (!L.Browser.safari) { 138 // By default, canvases copy things "on top of" existing pixels, but we want 139 // this to *replace* the existing pixels when doing a drawImage() call. 140 // This will also clear the sides, so no clearRect() calls are needed to make room 141 // for the new tiles. 142 level.ctx.globalCompositeOperation = "copy"; 143 level.ctx.drawImage(level.canvas, offset.x, offset.y); 144 level.ctx.globalCompositeOperation = "source-over"; 145 } else { 146 // Safari clears the canvas when copying from itself :-( 147 if (!this._tmpCanvas) { 148 var t = (this._tmpCanvas = L.DomUtil.create("canvas")); 149 t.width = level.canvas.width; 150 t.height = level.canvas.height; 151 this._tmpContext = t.getContext("2d"); 152 } 153 this._tmpContext.clearRect( 154 0, 155 0, 156 level.canvas.width, 157 level.canvas.height 158 ); 159 this._tmpContext.drawImage(level.canvas, 0, 0); 160 level.ctx.clearRect(0, 0, level.canvas.width, level.canvas.height); 161 level.ctx.drawImage(this._tmpCanvas, offset.x, offset.y); 162 } 163 164 level.ctx.setTransform(ratio, 0, 0, ratio, 0, 0); 165 mustRepositionCanvas = true; // Wait until new props are set 166 } 167 168 level.canvasRange = tileRange; 169 level.canvasPxRange = pixelRange; 170 level.canvasOrigin = pixelRange.min; 171 172 // console.log('Canvas tile range: ', level, tileRange.min, tileRange.max ); 173 // console.log('Canvas pixel range: ', pixelRange.min, pixelRange.max ); 174 // console.log('Level origin: ', level.origin ); 175 176 if (mustRepositionCanvas) { 177 this._setCanvasZoomTransform( 178 level, 179 this._map.getCenter(), 180 this._map.getZoom() 181 ); 182 } 183 }, 184 185 /// set transform/position of canvas, in addition to the transform/position of the individual tile container 186 _setZoomTransform: function(level, center, zoom) { 187 L.GridLayer.prototype._setZoomTransform.call(this, level, center, zoom); 188 if (this.options.dumpToCanvas) { 189 this._setCanvasZoomTransform(level, center, zoom); 190 } 191 }, 192 193 // This will get called twice: 194 // * From _setZoomTransform 195 // * When the canvas has shifted due to a new tile being loaded 196 _setCanvasZoomTransform: function(level, center, zoom) { 197 // console.log('_setCanvasZoomTransform', level, center, zoom); 198 if (!level.canvasOrigin) { 199 return; 200 } 201 var scale = this._map.getZoomScale(zoom, level.zoom), 202 translate = level.canvasOrigin 203 .multiplyBy(scale) 204 .subtract(this._map._getNewPixelOrigin(center, zoom)) 205 .round(); 206 207 if (L.Browser.any3d) { 208 L.DomUtil.setTransform(level.canvas, translate, scale); 209 } else { 210 L.DomUtil.setPosition(level.canvas, translate); 211 } 212 }, 213 214 _onOpaqueTile: function(tile) { 215 if (!this.options.dumpToCanvas) { 216 return; 217 } 218 219 // Guard against an NS_ERROR_NOT_AVAILABLE (or similar) exception 220 // when a non-image-tile has been loaded (e.g. a WMS error). 221 // Checking for tile.el.complete is not enough, as it has been 222 // already marked as loaded and ready somehow. 223 try { 224 this.dumpPixels(tile.coords, tile.el); 225 } catch (ex) { 226 return this.fire("tileerror", { 227 error: "Could not copy tile pixels: " + ex, 228 tile: tile, 229 coods: tile.coords, 230 }); 231 } 232 233 // If dumping the pixels was successful, then hide the tile. 234 // Do not remove the tile itself, as it is needed to check if the whole 235 // level (and its canvas) should be removed (via level.el.children.length) 236 tile.el.style.display = "none"; 237 }, 238 239 // @section Extension methods 240 // @uninheritable 241 242 // @method dumpPixels(coords: Object, imageSource: CanvasImageSource): this 243 // Dumps pixels from the given `CanvasImageSource` into the layer, into 244 // the space for the tile represented by the `coords` tile coordinates (an object 245 // like `{x: Number, y: Number, z: Number}`; the image source must have the 246 // same size as the `tileSize` option for the layer. Has no effect if `dumpToCanvas` 247 // is `false`. 248 dumpPixels: function(coords, imageSource) { 249 var level = this._levels[coords.z], 250 tileSize = this.getTileSize(); 251 252 if (!level.canvasRange || !this.options.dumpToCanvas) { 253 return; 254 } 255 256 // Check if the tile is inside the currently visible map bounds 257 // There is a possible race condition when tiles are loaded after they 258 // have been panned outside of the map. 259 if (!level.canvasRange.contains(coords)) { 260 this._resetCanvasSize(level); 261 } 262 263 // Where in the canvas should this tile go? 264 var offset = L.point(coords.x, coords.y) 265 .subtract(level.canvasRange.min) 266 .scaleBy(this.getTileSize()); 267 268 level.ctx.drawImage(imageSource, offset.x, offset.y, tileSize.x, tileSize.y); 269 270 // TODO: Clear the pixels of other levels' canvases where they overlap 271 // this newly dumped tile. 272 return this; 273 }, 274 });