Как-то давно я писал о том как можно в вебе использовать карты без сети и пытался сделать это с помощью гугло карт. К сожалению условия использования запрещали модифицировать ресурсы, а написанный мною код работал только с localstorage
, поэтому я решил перейти на светлую сторону силы, где код открыт, прост и понятен.
Чего же я хочу?
Я хочу сделать кэширование карты для полной работы без сети, те в первый раз загружаешь карту, просматриваешь интересующие тайлы (которые при этом будут кэшироваться) и в следующий раз карта с просмотренными тайлами будет полностью доступна без сети.
В принципе кэшировать на лету не обязательно и можно делать то же самое отдельно и для определенного региона. Но я всего лишь хочу показать подход.
Что же у нас есть?
В современном вебе для хранения наших данных могут подойти:
Application Cache — для статики, но не для тайлов.
Local Storage — с использованием base64 data uri, синхронно, поддерживается везде, но очень мало места.
Indexed DB — с использование base64 data uri, асинхронно, поддерживается в полноценных и мобильных хроме, ff, ie10.
Web SQL — с использование base64 data uri, асинхронно, обозначен как устаревший, поддерживается в полноценных и мобильных хроме, сафари, опере, браузере андроида.
File Writer — только хром.
Также можно попробовать использовать блобы и блоб урлы для уменьшения занимаемого тайлами места, но это может работать только вместе с Indexed DB. Эту затею я пока оставлю.
Итак, если комбинировать Application Cache, Indexed DB и Web SQL, то можно решить задачу хранения тайлов достаточную для нормального использования в современных браузерах, в том числе и мобильных.
Теория
В теории нам нужно:
- взять API;
- добавить всю статику в Application Cache;
- переопределить слой тайлов так, чтобы он загружал данные из наших асинхронных хранилищ;
- добавить логику по загрузке тайлов в хранилища.
Хранилище
Для начала организуем key-value хранилище с базовыми операциями add, delete, get для Indexed DB и Web SQL. Здесь есть одна магическая конструкция emr.fire('storageLoaded', storage);
, которая будет вызываться после того как хранилище проинициализировано и готово к использованию, чтобы карта не падала при обращении к хранилищу.
var getIndexedDBStorage = function () {
var indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
var IndexedDBImpl = function () {
var self = this;
var db = null;
var request = indexedDB.open('TileStorage');
request.onsuccess = function() {
db = this.result;
emr.fire('storageLoaded', self);
};
request.onerror = function (error) {
console.log(error);
};
request.onupgradeneeded = function () {
var store = this.result.createObjectStore('tile', { keyPath: 'key'});
store.createIndex('key', 'key', { unique: true });
};
this.add = function (key, value) {
var transaction = db.transaction(['tile'], 'readwrite');
var objectStore = transaction.objectStore('tile');
objectStore.put({key: key, value: value});
};
this.delete = function (key) {
var transaction = db.transaction(['tile'], 'readwrite');
var objectStore = transaction.objectStore('tile');
objectStore.delete(key);
};
this.get = function (key, successCallback, errorCallback) {
var transaction = db.transaction(['tile'], 'readonly');
var objectStore = transaction.objectStore('tile');
var result = objectStore.get(key);
result.onsuccess = function () {
successCallback(this.result ? this.result.value : undefined);
};
result.onerror = errorCallback;
};
};
return indexedDB ? new IndexedDBImpl() : null;
};
var getWebSqlStorage = function () {
var openDatabase = window.openDatabase;
var WebSqlImpl = function () {
var self = this;
var db = openDatabase('TileStorage', '1.0', 'Tile Storage', 5 * 1024 * 1024);
db.transaction(function (tx) {
tx.executeSql('CREATE TABLE IF NOT EXISTS tile (key TEXT PRIMARY KEY, value TEXT)', [], function () {
emr.fire('storageLoaded', self);
});
});
this.add = function (key, value) {
db.transaction(function (tx) {
tx.executeSql('INSERT INTO tile (key, value) VALUES (?, ?)', [key, value]);
});
};
this.delete = function (key) {
db.transaction(function (tx) {
tx.executeSql('DELETE FROM tile WHERE key = ?', [key]);
});
};
this.get = function (key, successCallback, errorCallback) {
db.transaction(function (tx) {
tx.executeSql('SELECT value FROM tile WHERE key = ?', [key], function (tx, result) {
successCallback(result.rows.length ? result.rows.item(0).value : undefined);
}, errorCallback);
});
};
};
return openDatabase ? new WebSqlImpl() : null;
};
var storage = getIndexedDBStorage() || getWebSqlStorage() || null;
if (!storage) {
emr.fire('storageLoaded', null);
}
Предлагаю считать данную реализацию очень схематичной, думаю здесь есть над чем подумать, например, чтобы не блокировать инициализацию карты, пока инициализируется хранилище; запоминать какие тайлы есть в хранилище без непосредственного обращения к API; попытаться объединить несколько операций сохранения в одну транзакцию, чтобы уменьшить количество записей на диск; попробовать использовать блобы там где они поддерживаются. Возможно реализация Indexed DB в старых браузерах будет падать, тк в них может быть не реализовано событие onupgradeneeded
.
IMG to data URI & CORS
Для того чтобы хранить тайлы нам нужно преобразовать их в data URI, то есть base64 представление. Для этого воспользуемся canvas и его методами toDataURL
или getImageData
:
_imageToDataUri: function (image) {
var canvas = window.document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
var context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
return canvas.toDataURL('image/png');
}
Так как html элемент img может принимать в качестве картинки любой доступный ресурс, в том числе и на авторизированных сервисах и локальной файловой системе, то возможность отправлять это содержимое третьей стороне представляет собой угрозу безопасности, поэтому картинки не разрешающие Access-Control-Allow-Origing
для Вашего домена сохранить будет нельзя. Благо тайлы mapnik или же tile.openstreetmap.org имеют заголовок Access-Control-Allow-Origing: *
, но для того чтобы все работало нужно установить флаг элемента img.crossOrigin
в значение Anonymous
.
Работа CORS в данной реализации во всех мобильных браузерах не гарантируется, поэтому проще всего настроить для своего сайта прокси на своем домене или отключить проверку CORS например для Phoengap адептов. Лично у меня данный код не взлетел в андроидовском браузере по умолчанию (androin 4.0.4 sony xperia active), а в опере некоторые тайлы сохранялись странным образом (сравни что иногда получается и то что должно быть на самом деле, но это похоже на баг оперы).
Здесь можно попробовать использовать WebWorkers
+ AJAX
вместо canvas
.
Leaflet
Итак нам понадобится популярное JS API карт с открытым кодом, одним из таких кандидатов является Leaflet.
Немного посмотрев исходники можно найти метод тайлового слоя, который отвечает за непосредственное указание src
для тайлов:
_loadTile: function (tile, tilePoint) {
tile._layer = this;
tile.onload = this._tileOnLoad;
tile.onerror = this._tileOnError;
tile.src = this.getTileUrl(tilePoint);
}
То есть если переопределить этот класс и непосредственно данный метод для загрузки данных в src
из хранилища, то мы сделаем то что нужно. Реализуем также добавление данных в хранилище, если они были загружены из сети и получим полноценное кэширование.
var StorageTileLayer = L.TileLayer.extend({
_imageToDataUri: function (image) {
var canvas = window.document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
var context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
return canvas.toDataURL('image/png');
},
_tileOnLoadWithCache: function () {
var storage = this._layer.options.storage;
if (storage) {
storage.add(this._storageKey, this._layer._imageToDataUri(this));
}
L.TileLayer.prototype._tileOnLoad.apply(this, arguments);
},
_setUpTile: function (tile, key, value, cache) {
tile._layer = this;
if (cache) {
tile._storageKey = key;
tile.onload = this._tileOnLoadWithCache;
tile.crossOrigin = 'Anonymous';
} else {
tile.onload = this._tileOnLoad;
}
tile.onerror = this._tileOnError;
tile.src = value;
},
_loadTile: function (tile, tilePoint) {
this._adjustTilePoint(tilePoint);
var key = tilePoint.z + ',' + tilePoint.y + ',' + tilePoint.x;
var self = this;
if (this.options.storage) {
this.options.storage.get(key, function (value) {
if (value) {
self._setUpTile(tile, key, value, false);
} else {
self._setUpTile(tile, key, self.getTileUrl(tilePoint), true);
}
}, function () {
self._setUpTile(tile, key, self.getTileUrl(tilePoint), true);
});
} else {
self._setUpTile(tile, key, self.getTileUrl(tilePoint), false);
}
}
});
Сама же карта в данном случае будет инициализироваться следующим образом:
var map = L.map('map').setView([53.902254, 27.561850], 13);
new StorageTileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {storage: storage}).addTo(map);
Также добавим наши ресурсы в Application Cache, чтобы карта могла полноценно работать без сети с закэшированными тайлами:
CACHE MANIFEST
NETWORK:
*
CACHE:
index.html
style.css
event.js
storage.js
map.js
run.js
leaflet.css
leaflet.js
images/layers.png
images/marker-icon.png
images/marker-icon@2x.png
images/marker-shadow.png
Mapbox (modesmaps)
Еще одним кандидатом открытого JS API карт является mapbox основанного на modesmaps.
Посмотрев исходники mapbox мы не найдем для нас ничего интересного, поэтому перейдем к исходникам modestmaps. Начнем с TemplatedLayer
, который является обычным слоем карты с шаблонным провайдером, те код который нам нужен будет находится в классе слоя:
MM.TemplatedLayer = function(template, subdomains, name) {
return new MM.Layer(new MM.Template(template, subdomains), null, name);
};
Найдя использования шаблонного провайдера в слое карты можно заметить, что наш провайдер может возвращать либо URL тайла, либо готовый DOM элемент, причем DOM элемент сразу позиционируется, а URL тайла пересылается в requestManager
:
if (!this.requestManager.hasRequest(tile_key)) {
var tileToRequest = this.provider.getTile(tile_coord);
if (typeof tileToRequest == 'string') {
this.addTileImage(tile_key, tile_coord, tileToRequest);
} else if (tileToRequest) {
this.addTileElement(tile_key, tile_coord, tileToRequest);
}
}
addTileImage: function(key, coord, url) {
this.requestManager.requestTile(key, coord, url);
}
addTileElement: function(key, coordinate, element) {
element.id = key;
element.coord = coordinate.copy();
this.positionTile(element);
}
Сам же requestManager
инициализируется в конструкторе слоя карты. Создание DOM элемента img
и установка его src
происходит в методе processQueue
, который также дергается из слоя карты:
processQueue: function(sortFunc) {
if (sortFunc && this.requestQueue.length > 8) {
this.requestQueue.sort(sortFunc);
}
while (this.openRequestCount < this.maxOpenRequests && this.requestQueue.length > 0) {
var request = this.requestQueue.pop();
if (request) {
this.openRequestCount++;
var img = document.createElement('img');
img.id = request.id;
img.style.position = 'absolute';
img.coord = request.coord;
this.loadingBay.appendChild(img);
img.onload = img.onerror = this.getLoadComplete();
img.src = request.url;
request = request.id = request.coord = request.url = null;
}
}
}
То есть если мы переопределим данный метод, то также получим желаемый результат.
var StorageRequestManager = function (storage) {
MM.RequestManager.apply(this, []);
this._storage = storage;
};
StorageRequestManager.prototype._imageToDataUri = function (image) {
var canvas = window.document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
var context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
return canvas.toDataURL('image/png');
};
StorageRequestManager.prototype._createTileImage = function (id, coord, value, cache) {
var img = window.document.createElement('img');
img.id = id;
img.style.position = 'absolute';
img.coord = coord;
this.loadingBay.appendChild(img);
if (cache) {
img.onload = this.getLoadCompleteWithCache();
img.crossOrigin = 'Anonymous';
} else {
img.onload = this.getLoadComplete();
}
img.onerror = this.getLoadComplete();
img.src = value;
};
StorageRequestManager.prototype._loadTile = function (id, coord, url) {
var self = this;
if (this._storage) {
this._storage.get(id, function (value) {
if (value) {
self._createTileImage(id, coord, value, false);
} else {
self._createTileImage(id, coord, url, true);
}
}, function () {
self._createTileImage(id, coord, url, true);
});
} else {
self._createTileImage(id, coord, url, false);
}
};
StorageRequestManager.prototype.processQueue = function (sortFunc) {
if (sortFunc && this.requestQueue.length > 8) {
this.requestQueue.sort(sortFunc);
}
while (this.openRequestCount < this.maxOpenRequests && this.requestQueue.length > 0) {
var request = this.requestQueue.pop();
if (request) {
this.openRequestCount++;
this._loadTile(request.id, request.coord, request.url);
request = request.id = request.coord = request.url = null;
}
}
};
StorageRequestManager.prototype.getLoadCompleteWithCache = function () {
if (!this._loadComplete) {
var theManager = this;
this._loadComplete = function(e) {
e = e || window.event;
var img = e.srcElement || e.target;
img.onload = img.onerror = null;
if (theManager._storage) {
theManager._storage.add(this.id, theManager._imageToDataUri(this));
}
theManager.loadingBay.removeChild(img);
theManager.openRequestCount--;
delete theManager.requestsById[img.id];
if (e.type === 'load' && (img.complete ||
(img.readyState && img.readyState === 'complete'))) {
theManager.dispatchCallback('requestcomplete', img);
} else {
theManager.dispatchCallback('requesterror', {
element: img,
url: ('' + img.src)
});
img.src = null;
}
setTimeout(theManager.getProcessQueue(), 0);
};
}
return this._loadComplete;
};
MM.extend(StorageRequestManager, MM.RequestManager);
var StorageLayer = function(provider, parent, name, storage) {
this.parent = parent || document.createElement('div');
this.parent.style.cssText = 'position: absolute; top: 0px; left: 0px;' +
'width: 100%; height: 100%; margin: 0; padding: 0; z-index: 0';
this.name = name;
this.levels = {};
this.requestManager = new StorageRequestManager(storage);
this.requestManager.addCallback('requestcomplete', this.getTileComplete());
this.requestManager.addCallback('requesterror', this.getTileError());
if (provider) {
this.setProvider(provider);
}
};
MM.extend(StorageLayer, MM.Layer);
var StorageTemplatedLayer = function(template, subdomains, name, storage) {
return new StorageLayer(new MM.Template(template, subdomains), null, name, storage);
};
Сама же карта в данном случае будет инициализироваться следующим образом:
var map = mapbox.map('map');
map.addLayer(new StorageTemplatedLayer('http://{S}.tile.osm.org/{Z}/{X}/{Y}.png', ['a', 'b', 'c'], undefined, storage));
map.ui.zoomer.add();
map.ui.zoombox.add();
map.centerzoom({lat: 53.902254, lon: 27.561850}, 13);
Также добавим наши ресурсы в Application Cache, чтобы карта могла полноценно работать без сети с закэшированными тайлами:
CACHE MANIFEST
NETWORK:
*
CACHE:
index.html
style.css
event.js
storage.js
map.js
run.js
mapbox.css
mapbox.js
map-controls.png
OpenLayers
И последним кандидатом открытого JS API карт является OpenLayers.
Мне пришлось потратить какое-то время чтобы разобраться как запустить минимальный вид, в итоге мой файл для сборки приобрел следующий вид:
[first]
[last]
[include]
OpenLayers/Map.js
OpenLayers/Layer/OSM.js
OpenLayers/Control/Zoom.js
OpenLayers/Control/Navigation.js
OpenLayers/Control/TouchNavigation.js
[exclude]
Я буду использовать OpenLayers.Layer.OSM
, поэтому начну поиск с него:
url: [
'http://a.tile.openstreetmap.org/${z}/${x}/${y}.png',
'http://b.tile.openstreetmap.org/${z}/${x}/${y}.png',
'http://c.tile.openstreetmap.org/${z}/${x}/${y}.png'
]
OpenLayers.Layer.OSM
наследуется от OpenLayers.Layer.XYZ
с переопределенными URL. Здесь интересен метод getURL
:
getURL: function (bounds) {
var xyz = this.getXYZ(bounds);
var url = this.url;
if (OpenLayers.Util.isArray(url)) {
var s = '' + xyz.x + xyz.y + xyz.z;
url = this.selectUrl(s, url);
}
return OpenLayers.String.format(url, xyz);
}
Также интересен метод getXYZ
, который можно использовать для создания ключа:
getXYZ: function(bounds) {
var res = this.getServerResolution();
var x = Math.round((bounds.left - this.maxExtent.left) /
(res * this.tileSize.w));
var y = Math.round((this.maxExtent.top - bounds.top) /
(res * this.tileSize.h));
var z = this.getServerZoom();
if (this.wrapDateLine) {
var limit = Math.pow(2, z);
x = ((x % limit) + limit) % limit;
}
return {'x': x, 'y': y, 'z': z};
}
Сам OpenLayers.Layer.XYZ
наследуется от OpenLayers.Layer.Grid
, у которого есть метод addTile
и который внутри себя создает тайлы с помощью tileClass
, которым является OpenLayers.Tile.Image
:
addTile: function(bounds, position) {
var tile = new this.tileClass(
this, position, bounds, null, this.tileSize, this.tileOptions
);
this.events.triggerEvent("addtile", {tile: tile});
return tile;
}
В OpenLayers.Tile.Image
src
задается в методе setImgSrc
:
setImgSrc: function(url) {
var img = this.imgDiv;
if (url) {
img.style.visibility = 'hidden';
img.style.opacity = 0;
if (this.crossOriginKeyword) {
if (url.substr(0, 5) !== 'data:') {
img.setAttribute("crossorigin", this.crossOriginKeyword);
} else {
img.removeAttribute("crossorigin");
}
}
img.src = url;
} else {
this.stopLoading();
this.imgDiv = null;
if (img.parentNode) {
img.parentNode.removeChild(img);
}
}
}
Но в нем не задаются обработчики onload
и onerror
. Сам метод дергается из initImage
, где эти обработчики и вешаются:
initImage: function() {
this.events.triggerEvent('beforeload');
this.layer.div.appendChild(this.getTile());
this.events.triggerEvent(this._loadEvent);
var img = this.getImage();
if (this.url && img.getAttribute("src") == this.url) {
this._loadTimeout = window.setTimeout(
OpenLayers.Function.bind(this.onImageLoad, this), 0
);
} else {
this.stopLoading();
if (this.crossOriginKeyword) {
img.removeAttribute("crossorigin");
}
OpenLayers.Event.observe(img, "load",
OpenLayers.Function.bind(this.onImageLoad, this)
);
OpenLayers.Event.observe(img, "error",
OpenLayers.Function.bind(this.onImageError, this)
);
this.imageReloadAttempts = 0;
this.setImgSrc(this.url);
}
}
Можно заметить, что метод класса слоя getURL
, а также initImage
, дергаются из renderTile
:
renderTile: function() {
if (this.layer.async) {
var id = this.asyncRequestId = (this.asyncRequestId || 0) + 1;
this.layer.getURLasync(this.bounds, function(url) {
if (id == this.asyncRequestId) {
this.url = url;
this.initImage();
}
}, this);
} else {
this.url = this.layer.getURL(this.bounds);
this.initImage();
}
}
Итак если мы переопределим данный класс, то также получим желаемый результат.
var StorageImageTile = OpenLayers.Class(OpenLayers.Tile.Image, {
_imageToDataUri: function (image) {
var canvas = window.document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
var context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
return canvas.toDataURL('image/png');
},
onImageLoadWithCache: function() {
if (this.storage) {
this.storage.add(this._storageKey, this._imageToDataUri(this.imgDiv));
}
this.onImageLoad.apply(this, arguments);
},
renderTile: function() {
var self = this;
var xyz = this.layer.getXYZ(this.bounds);
var key = xyz.z + ',' + xyz.y + ',' + xyz.x;
var url = this.layer.getURL(this.bounds);
if (this.storage) {
this.storage.get(key, function (value) {
if (value) {
self.initImage(key, value, false);
} else {
self.initImage(key, url, true);
}
}, function () {
self.initImage(key, url, true);
});
} else {
self.initImage(key, url, false);
}
},
initImage: function(key, url, cache) {
this.events.triggerEvent('beforeload');
this.layer.div.appendChild(this.getTile());
this.events.triggerEvent(this._loadEvent);
var img = this.getImage();
this.stopLoading();
if (cache) {
OpenLayers.Event.observe(img, 'load',
OpenLayers.Function.bind(this.onImageLoadWithCache, this)
);
this._storageKey = key;
} else {
OpenLayers.Event.observe(img, 'load',
OpenLayers.Function.bind(this.onImageLoad, this)
);
}
OpenLayers.Event.observe(img, 'error',
OpenLayers.Function.bind(this.onImageError, this)
);
this.imageReloadAttempts = 0;
this.setImgSrc(url);
}
});
var StorageOSMLayer = OpenLayers.Class(OpenLayers.Layer.OSM, {
async: true,
tileClass: StorageImageTile,
initialize: function(name, url, options) {
OpenLayers.Layer.OSM.prototype.initialize.apply(this, arguments);
this.tileOptions = OpenLayers.Util.extend({
storage: options.storage
}, this.options && this.options.tileOptions);
},
clone: function (obj) {
if (obj == null) {
obj = new StorageOSMLayer(this.name,
this.url,
this.getOptions());
}
obj = OpenLayers.Layer.Grid.prototype.clone.apply(this, [obj]);
return obj;
}
});
Сама же карта в данном случае будет инициализироваться следующим образом:
var map = new OpenLayers.Map('map');
map.addLayer(new StorageOSMLayer(undefined, undefined, {storage: storage}));
var fromProjection = new OpenLayers.Projection('EPSG:4326');
var toProjection = new OpenLayers.Projection('EPSG:900913');
var center = new OpenLayers.LonLat(27.561850, 53.902254).transform(fromProjection, toProjection);
map.setCenter(center, 13);
Также добавим наши ресурсы в Application Cache, чтобы карта могла полноценно работать без сети с закэшированными тайлами:
CACHE MANIFEST
NETWORK:
*
CACHE:
index.html
style.css
event.js
storage.js
map.js
run.js
theme/default/style.css
OpenLayers.js
Еще я нашел уже готовую реализацию кэша OpenLayers.Control.CacheWrite
, но только с использованием localStorage
, что не очень интересно.
Заключение
Собственно я получил что хотел. Примеры шустро работают в хроме или если у Вас SSD, в лисе и опере наблюдаются тормаза при сохранении на диск, осла под рукой не оказалось. Тормоза проявляются и в мобильных браузерах, причем даже при чтении, что немного меня расстроило, тк для мобильных устройств данная задача более актуальна.
Стандартного размера хранилища IndexedDB
или WebSQL
вполне хватит чтобы закэшировать город или больше, что делает применение подхода более интересным чем в варианте с localstrage
.
В моих примерах можно работать с асинхронными хранилищами, но для большего комфорта нужно еще поработать над их реализацией, чтобы повысить производительность по сравнению с тем что есть сейчас.
Автор: tbicr