Skip to content

Instantly share code, notes, and snippets.

@RhinoLance
Created June 26, 2014 15:28
Show Gist options
  • Save RhinoLance/e8b06ba45ea10a58ea16 to your computer and use it in GitHub Desktop.
Save RhinoLance/e8b06ba45ea10a58ea16 to your computer and use it in GitHub Desktop.
Leaflet offline tile caching
/*global L, LocalFileSystem, RhinoLib, async, FileTransfer, Connection */
var cacheMode = {
PRE_CACHE: 0x1,
CACHE_IN_BOUNDS: 0x2,
CACHE_OUTSIDE_BOUNDS: 0x4
};
L.TileLayer.CachedTiles = L.TileLayer.extend({
_fileStorePath: 'MapTiles',
initialize: function (url, options) {
options = L.setOptions(this, options);
options.cacheThreads = options.cacheThreads || 10;
options.cacheBounds = options.cacheBounds || [];
options.cacheActions = options.cacheActions || 255; //allow everything
options.name = options.name || "defaultMapCache";
// detecting retina displays, adjusting tileSize and zoom levels
if (options.detectRetina && L.Browser.retina && options.maxZoom > 0) {
options.tileSize = Math.floor(options.tileSize / 2);
options.zoomOffset++;
if (options.minZoom > 0) {
options.minZoom--;
}
this.options.maxZoom--;
}
this._url = url;
var subdomains = this.options.subdomains;
if (typeof subdomains === 'string') {
this.options.subdomains = subdomains.split('');
}
this._sFileExt = url.substr(url.lastIndexOf('.') + 1);
this.tileStore = null;
this._tileStoreDefer = null;
this.cacheBounds = this.getCacheBounds(options.cacheBounds);
},
getFileStore: function () {
var that = this;
if (that._tileStoreDefer != null) {
return this._tileStoreDefer.promise;
}
that._tileStoreDefer = Q.defer();
var path = that._fileStorePath + '/' + that.options.name.split(' ').join('_');
RhinoLib.File.requestFileSystem(RhinoLib.File.LocalFileSystem.PERSISTENT)
.then(function (fs) {
that.tileStore = fs.root;
return that._buildFolderStructure(path);
})
.then(function (dirEntry) {
that.tileStore = dirEntry;
that._tileStoreDefer.resolve(that.tileStore)
});
return that._tileStoreDefer.promise;
},
xx_setFileStore: function (fCallback) {
var that = this;
if (typeof cordova !== 'undefined') { //only do the caching if it's a cordova platform
console.log('Setting FileStore');
window.requestFileSystem(LocalFileSystem.PERSISTENT, 0,
function (oFs) {
that.tileStore = oFs.root;
console.log('root: ' + oFs.root)
var path = that._fileStorePath + '/' + that.options.name.split(' ').join('_');
that._buildFolderStructure( path,
function () {
oFs.root.getDirectory(path, { create: true, exclusive: false },
function (oDir) {
that.tileStore = oDir;
if( fCallback){fCallback();}
},
function (oError) {
console.error("Unable to get root directory: " + JSON.stringify(oError));
}
);
}
);
},
function (oError) {
console.error("Unable to get filesystem: " + JSON.stringify(oError));
}
);
}
},
getTileUrl: function (tilePoint, zLayer) {
return L.Util.template(this._url, L.extend({
s: this._getSubdomain(tilePoint),
z: zLayer || this._getZoomForUrl(),
x: tilePoint.x,
y: tilePoint.y
}, this.options));
},
_loadTile: function (tile, tilePoint) {
//try{
var that = this;
tile._layer = this;
tile.onload = this._tileOnLoad;
tile.onerror = this._tileOnError;
tilePoint.z = this._getZoomForUrl();
if (!this.tileStore) {
//console.log("Getting from web");
tile.src = this.getTileUrl(tilePoint);
return;
}
var sFileName = this._getFilePath({ x: tilePoint.x, y: tilePoint.y, z: tilePoint.z });
//console.log("fileName " + sFileName);
this.tileStore.getFile(
sFileName,
null,
function (oFile) {
//console.log("Tile loaded from cache ");
tile.src = oFile.toURL();
},
function (oError) {
//console.log("Cached tile not found, fetching it ...");
try{
if (!that.canCache(tilePoint)) {
//console.log('cancache: No');
tile.src = that.getTileUrl(tilePoint);
return;
}
} catch (ex) { console.error("err qwe : " + ex + " \n" + JSON.stringify(ex)); }
//console.log('no local tile');
that._downloadTile(
{ x: tilePoint.x, y: tilePoint.y, z: tilePoint.z },
function (oResult) {
//console.log('download done.');
if (oResult.status == "success") {
tile.src = oResult.data.toURL();
//console.log("loaded from new cache" );
}
}
);
}
);
//} catch (ex) { console.log("err abc: " + ex + " \n" + JSON.stringify(ex)); }
},
canCache: function( tilePoint ) {
//Check if we're allowed to cache
var canCache = false;
if (this.options.cacheActions & cacheMode.CACHE_OUTSIDE_BOUNDS) {
canCache = true;
}
else {
if (this.options.cacheActions & cacheMode.CACHE_IN_BOUNDS) {
if (this.cacheBounds.contains(tilePoint)) {
canCache = true;
}
}
}
return canCache;
},
getCacheBounds: function (cacheSettings) {
var cache;
var that = this;
cache = {
bounds: [],
contains: function (tilePoint) {
for (var cI = 0; cI < that.cacheBounds.bounds.length; cI++) {
//Check if it's allowed for the current zoom
if (tilePoint.z !== that.cacheBounds.bounds[cI].zoom) {
//console.log("outside cache bounds zoom");
return false;
}
//Check if it's in he onvelope
if (tilePoint.x > that.cacheBounds.bounds[cI].topLeft &&
tilePoint.y > that.cacheBounds.bounds[cI].topLeft &&
tilePoint.x < that.cacheBounds.bounds[cI].bottomRight &&
tilePoint.y < that.cacheBounds.bounds[cI].bottomRight) {
//console.log("in cache bounds envelope");
return true;
}
else {
//console.log("not in cache bounds envelope");
return false;
}
}
}
};
for (var cK = 0; cK < cacheSettings.length; cK++) { //main settings
var bounds = cacheSettings[cK].bounds;
for (var cI = 0; cI < cacheSettings[cK].zooms.length; cI++) {
var iZoom = cacheSettings[cK].zooms[cI];
var topLeft = RhinoLib.GIS.getTilePoint(bounds[0][0], bounds[0][1], iZoom);
var bottomRight = RhinoLib.GIS.getTilePoint(bounds[1][0], bounds[1][1], iZoom);
//Check that coords have been supplied correctly and skip if not.
if (topLeft.y > bottomRight.y || topLeft.x > bottomRight.x) {
continue;
}
cache.bounds.push(
{
topLeft: topLeft,
bottomRight: bottomRight,
zoom: iZoom
}
);
}
}
return cache;
},
getCacheBoundsLayer: function() {
if (!this.boundsLayer) {
this.boundsLayer = new L.FeatureGroup();
for (var cK = 0; cK < this.options.cacheBounds.length; cK++) { //main settings
var zone = L.rectangle(this.options.cacheBounds[cK].bounds, { color: "#ff7800", weight: 1 });
zone.bindPopup("Cache at zooms: " + JSON.stringify(this.options.cacheBounds[cK].zooms));
zone.addTo(this.boundsLayer);
}
}
return this.boundsLayer;
},
startPreCache: function( fCallback ) {
var that = this;
that.getFileStore()
.then(function (fs) {
that._startPreCache(fCallback);
});
},
_startPreCache: function (fCallback) {
var that = this;
console.log('starting preCache');
fCallback(0);
if (typeof cordova === 'undefined') { //only do the caching if it's a cordova platform
fCallback(100);
return;
}
if (navigator.connection.type === Connection.NONE) {
//No point trying to download if there is no connection.
fCallback(100);
return;
}
if (!(this.options.cacheActions & cacheMode.PRE_CACHE)) {
//console.log("No permission to pre-cache");
//No permission to pre-cache
fCallback(100);
return;
}
var aTiles = [];
var folderHash = [];
for (var cK = 0; cK < this.cacheBounds.bounds.length; cK++) {
var bounds = this.cacheBounds.bounds[cK];
//Create an array with all tiles to cache
for (var cJ = bounds.topLeft.x; cJ <= bounds.bottomRight.x; cJ++) {
//While we're here, create the folder, so that we can download in parallel later.
var folder = this._getFolderPath({ x: cJ, z: bounds.zoom });
if (folderHash.indexOf(folder) === -1) { //We only want to create each folder once.
folderHash.push(folder);
this._buildFolderStructure(
folder
);
}
for (var cI = bounds.topLeft.y; cI <= bounds.bottomRight.y; cI++) {
aTiles.push({ x: cJ, y: cI, z: bounds.zoom });
}
}
}
var that = this;
var iCount = -1;
async.eachLimit( //initiate caching
aTiles,
that.options.cacheThreads,
function (item, fItemCb) {
//Let's update our progress
if (iCount++ % 10 === 0) {
fCallback(Math.floor(iCount * (100 / aTiles.length)));
}
//Check if we already have the tile.
that.tileStore.getFile(
that._getFilePath(item),
null,
function (oFile) {
//it exists
fItemCb();
},
function (oError) {
//console.log('no local tile');
that._downloadTile(
item,
function (oResult) {
if (oResult.status == "fail") {
//console.log( "download error: " + JSON.stringify( oResult ));
if (oResult.data.code == 3) { //file not found, keep going
fItemCb();
}
else {
fItemCb(oResult.data);
}
}
else {
fItemCb();
}
}
);
});
},
function(oErr){
if( oErr ) {
//console.log("Cache completed. " + aTiles.length + " tiles cached. Errors: " + JSON.stringify(oErr));
}
fCallback( 100 );
}
);
},
_getFilePath: function(oTile) {
return this.tileStore.fullPath + "/" + this._getFolderPath(oTile) + "/" + oTile.y + "." + this._sFileExt;
},
_getFolderPath: function(oTile) {
return oTile.z + "/" + oTile.x;
},
_downloadTile: function( oTile, fCallback ) {
if (navigator.connection.type === Connection.NONE) {
//No point trying to download if there is no connection.
fCallback({ status: "fail", data: {desc: "No network connection"} });
return;
}
var fileTransfer;
try{
fileTransfer = new FileTransfer();
}
catch(oErr){
fCallback({ status: "success", data: {} });
return;
}
var that = this;
async.series([
//Let's make sure the directory structure exists for this tile.
function(fCbAsync) {
that.tileStore.getDirectory(
that._getFolderPath(oTile),
{ create: true, exclusive: false },
function (oDir) {
fCbAsync();
},
function (oErr) {
that._buildFolderStructure( that._getFolderPath(oTile))
.then( fCbAsync );
}
);
},
//Now download it
function(fCbAsync) {
var src = that.getTileUrl(oTile, oTile.z)
var dest = 'cdvfile://localhost/persistent/' + that._getFilePath(oTile)
fileTransfer.download( src, dest,
function (oFile) {
//console.log("Cached " + oFile.name);
fCallback({ status: "success", data: oFile });
},
function (oErr) {
//console.log("Cache failed for " + that.getTileUrl(oTile, oTile.z) + ": " + JSON.stringify(oErr));
fCallback({ status: "fail", data: oErr });
}
);
}
]);
},
_buildFolderStructure: function( sFolderPath ) {
var deferred = Q.defer();
//console.log('c1');
var that = this;
var aParts = sFolderPath.split('/');
var currItem = null;
var currDir = that.tileStore;
//console.log('c2');
async.eachSeries(
aParts,
function (item, fItemCb) {
//console.log("Creating directory: " + item);
currItem = item;
currDir.getDirectory(
item,
{create: true, exclusive: false},
function (oDir) {
//console.log('c3');
currDir = oDir;
fItemCb();
},
fItemCb
);
},
function (oErr) {
if (oErr) {
console.error("Error creating folder structure: " + currItem + " Errors: " + JSON.stringify(oErr));
deferred.reject(err);
}
else {
//console.log("Folder succesfully created: " + sFolderPath);
deferred.resolve(currDir);
}
}
);
return deferred.promise;
},
deleteCache: function (fCallback) {
if( typeof cordova === 'undefined') { //only do the caching if it's a cordova platform
fCallback();
return;
}
var that = this;
//console.log('a1');
var fDone = function (oErr) {
//console.log('a2');
that.getFileStore(); //Re-create the directory
//console.log('a3');
fCallback(oErr);
};
that.getFileStore()
.then(
function () {
that.tileStore.removeRecursively(fDone, fDone);
}
);
}
});
L.Icon.TextIcon = L.Icon.extend({
options: {
/*
iconUrl: (String) (required)
iconRetinaUrl: (String) (optional, used for retina devices if detected)
iconSize: (Point) (can be set through CSS)
iconAnchor: (Point) (centered by default, can be set in CSS with negative margins)
popupAnchor: (Point) (if not specified, popup opens in the anchor point)
shadowUrl: (Point) (no shadow by default)
shadowRetinaUrl: (String) (optional, used for retina devices if detected)
shadowSize: (Point)
shadowAnchor: (Point)
*/
className: ''
},
createIcon: function () {
var options = this.options;
options.className += " leaflet-marker-textIcon";
var div = document.createElement('div');
div.innerHTML = "<div data-markerIcon='" + options.dataIcon + "' class='leaflet-marker-textIcon-inner'></div>";
div.style.background = "url(" + options.iconUrl + ") no-repeat no-repeat 0 0";
if (options.bgPos) {
div.style.backgroundPosition =
(-options.bgPos.x) + 'px ' + (-options.bgPos.y) + 'px';
}
div.style.height = options.iconSize[1] + "px";
div.style.width = options.iconSize[0] + "px";
div.style.backgroundSize = "100%";
this._setIconStyles(div, 'icon');
return div;
},
createShadow: function () {
//return;
var div = document.createElement('div'),
options = this.options;
this._setIconStyles(div, 'shadow');
return div;
}
});
L.icon.textIcon = function (options) {
return new L.Icon.TextIcon(options);
};
L.Marker.prototype.animateDragging = function () {
var iconMargin, shadowMargin;
this.on('dragstart', function () {
if (!iconMargin) {
iconMargin = parseInt(L.DomUtil.getStyle(this._icon, 'marginTop'));
shadowMargin = parseInt(L.DomUtil.getStyle(this._shadow, 'marginLeft'));
}
this._icon.style.marginTop = (iconMargin - 25) + 'px';
this._shadow.style.marginLeft = (shadowMargin + 8) + 'px';
});
return this.on('dragend', function () {
this._icon.style.marginTop = iconMargin + 'px';
this._shadow.style.marginLeft = shadowMargin + 'px';
});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment