leaflet-mapbox-vector-tile
Version:
A Leaflet Plugin that renders Mapbox Vector Tiles on HTML5 Canvas.
527 lines (440 loc) • 15.4 kB
JavaScript
var VectorTile = require('vector-tile').VectorTile;
var Protobuf = require('pbf');
var Point = require('point-geometry');
var Util = require('./MVTUtil');
var MVTLayer = require('./MVTLayer');
module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({
options: {
debug: false,
url: "", //URL TO Vector Tile Source,
getIDForLayerFeature: function() {},
tileSize: 256,
visibleLayers: []
},
layers: {}, //Keep a list of the layers contained in the PBFs
processedTiles: {}, //Keep a list of tiles that have been processed already
_eventHandlers: {},
_triggerOnTilesLoadedEvent: true, //whether or not to fire the onTilesLoaded event when all of the tiles finish loading.
_url: "", //internal URL property
style: function(feature) {
var style = {};
var type = feature.type;
switch (type) {
case 1: //'Point'
style.color = 'rgba(49,79,79,1)';
style.radius = 5;
style.selected = {
color: 'rgba(255,255,0,0.5)',
radius: 6
};
break;
case 2: //'LineString'
style.color = 'rgba(161,217,155,0.8)';
style.size = 3;
style.selected = {
color: 'rgba(255,25,0,0.5)',
size: 4
};
break;
case 3: //'Polygon'
style.color = 'rgba(49,79,79,1)';
style.outline = {
color: 'rgba(161,217,155,0.8)',
size: 1
};
style.selected = {
color: 'rgba(255,140,0,0.3)',
outline: {
color: 'rgba(255,140,0,1)',
size: 2
}
};
break;
}
return style;
},
initialize: function(options) {
L.Util.setOptions(this, options);
//a list of the layers contained in the PBFs
this.layers = {};
// tiles currently in the viewport
this.activeTiles = {};
// thats that have been loaded and drawn
this.loadedTiles = {};
this._url = this.options.url;
/**
* For some reason, Leaflet has some code that resets the
* z index in the options object. I'm having trouble tracking
* down exactly what does this and why, so for now, we should
* just copy the value to this.zIndex so we can have the right
* number when we make the subsequent MVTLayers.
*/
this.zIndex = options.zIndex;
if (typeof options.style === 'function') {
this.style = options.style;
}
if (typeof options.ajaxSource === 'function') {
this.ajaxSource = options.ajaxSource;
}
this.layerLink = options.layerLink;
this._eventHandlers = {};
this._tilesToProcess = 0; //store the max number of tiles to be loaded. Later, we can use this count to count down PBF loading.
},
redraw: function(triggerOnTilesLoadedEvent){
//Only set to false if it actually is passed in as 'false'
if (triggerOnTilesLoadedEvent === false) {
this._triggerOnTilesLoadedEvent = false;
}
L.TileLayer.Canvas.prototype.redraw.call(this);
},
onAdd: function(map) {
var self = this;
self.map = map;
L.TileLayer.Canvas.prototype.onAdd.call(this, map);
var mapOnClickCallback = function(e) {
self._onClick(e);
};
map.on('click', mapOnClickCallback);
map.on("layerremove", function(e) {
// check to see if the layer removed is this one
// call a method to remove the child layers (the ones that actually have something drawn on them).
if (e.layer._leaflet_id === self._leaflet_id && e.layer.removeChildLayers) {
e.layer.removeChildLayers(map);
map.off('click', mapOnClickCallback);
}
});
self.addChildLayers(map);
if (typeof DynamicLabel === 'function' ) {
this.dynamicLabel = new DynamicLabel(map, this, {});
}
},
drawTile: function(canvas, tilePoint, zoom) {
var ctx = {
id: [zoom, tilePoint.x, tilePoint.y].join(":"),
canvas: canvas,
tile: tilePoint,
zoom: zoom,
tileSize: this.options.tileSize
};
//Capture the max number of the tiles to load here. this._tilesToProcess is an internal number we use to know when we've finished requesting PBFs.
if(this._tilesToProcess < this._tilesToLoad) this._tilesToProcess = this._tilesToLoad;
var id = ctx.id = Util.getContextID(ctx);
this.activeTiles[id] = ctx;
if(!this.processedTiles[ctx.zoom]) this.processedTiles[ctx.zoom] = {};
if (this.options.debug) {
this._drawDebugInfo(ctx);
}
this._draw(ctx);
},
setOpacity:function(opacity) {
this._setVisibleLayersStyle('opacity',opacity);
},
setZIndex:function(zIndex) {
this._setVisibleLayersStyle('zIndex',zIndex);
},
_setVisibleLayersStyle:function(style, value) {
for(var key in this.layers) {
this.layers[key]._tileContainer.style[style] = value;
}
},
_drawDebugInfo: function(ctx) {
var max = this.options.tileSize;
var g = ctx.canvas.getContext('2d');
g.strokeStyle = '#000000';
g.fillStyle = '#FFFF00';
g.strokeRect(0, 0, max, max);
g.font = "12px Arial";
g.fillRect(0, 0, 5, 5);
g.fillRect(0, max - 5, 5, 5);
g.fillRect(max - 5, 0, 5, 5);
g.fillRect(max - 5, max - 5, 5, 5);
g.fillRect(max / 2 - 5, max / 2 - 5, 10, 10);
g.strokeText(ctx.zoom + ' ' + ctx.tile.x + ' ' + ctx.tile.y, max / 2 - 30, max / 2 - 10);
},
_draw: function(ctx) {
var self = this;
// //This works to skip fetching and processing tiles if they've already been processed.
// var vectorTile = this.processedTiles[ctx.zoom][ctx.id];
// //if we've already parsed it, don't get it again.
// if(vectorTile){
// console.log("Skipping fetching " + ctx.id);
// self.checkVectorTileLayers(parseVT(vectorTile), ctx, true);
// self.reduceTilesToProcessCount();
// return;
// }
if (!this._url) return;
var src = this.getTileUrl({ x: ctx.tile.x, y: ctx.tile.y, z: ctx.zoom });
var xhr = new XMLHttpRequest();
xhr.onload = function() {
if (xhr.status == "200") {
if(!xhr.response) return;
var arrayBuffer = new Uint8Array(xhr.response);
var buf = new Protobuf(arrayBuffer);
var vt = new VectorTile(buf);
//Check the current map layer zoom. If fast zooming is occurring, then short circuit tiles that are for a different zoom level than we're currently on.
if(self.map && self.map.getZoom() != ctx.zoom) {
console.log("Fetched tile for zoom level " + ctx.zoom + ". Map is at zoom level " + self._map.getZoom());
return;
}
self.checkVectorTileLayers(parseVT(vt), ctx);
tileLoaded(self, ctx);
}
//either way, reduce the count of tilesToProcess tiles here
self.reduceTilesToProcessCount();
};
xhr.onerror = function() {
console.log("xhr error: " + xhr.status)
};
xhr.open('GET', src, true); //async is true
xhr.responseType = 'arraybuffer';
xhr.send();
},
reduceTilesToProcessCount: function(){
this._tilesToProcess--;
if(!this._tilesToProcess){
//Trigger event letting us know that all PBFs have been loaded and processed (or 404'd).
if(this._eventHandlers["PBFLoad"]) this._eventHandlers["PBFLoad"]();
this._pbfLoaded();
}
},
checkVectorTileLayers: function(vt, ctx, parsed) {
var self = this;
//Check if there are specified visible layers
if(self.options.visibleLayers && self.options.visibleLayers.length > 0){
//only let thru the layers listed in the visibleLayers array
for(var i=0; i < self.options.visibleLayers.length; i++){
var layerName = self.options.visibleLayers[i];
if(vt.layers[layerName]){
//Proceed with parsing
self.prepareMVTLayers(vt.layers[layerName], layerName, ctx, parsed);
}
}
}else{
//Parse all vt.layers
for (var key in vt.layers) {
self.prepareMVTLayers(vt.layers[key], key, ctx, parsed);
}
}
},
prepareMVTLayers: function(lyr ,key, ctx, parsed) {
var self = this;
if (!self.layers[key]) {
//Create MVTLayer or MVTPointLayer for user
self.layers[key] = self.createMVTLayer(key, lyr.parsedFeatures[0].type || null);
}
if (parsed) {
//We've already parsed it. Go get canvas and draw.
self.layers[key].getCanvas(ctx, lyr);
} else {
self.layers[key].parseVectorTileLayer(lyr, ctx);
}
},
createMVTLayer: function(key, type) {
var self = this;
var getIDForLayerFeature;
if (typeof self.options.getIDForLayerFeature === 'function') {
getIDForLayerFeature = self.options.getIDForLayerFeature;
} else {
getIDForLayerFeature = Util.getIDForLayerFeature;
}
var options = {
getIDForLayerFeature: getIDForLayerFeature,
filter: self.options.filter,
layerOrdering: self.options.layerOrdering,
style: self.style,
name: key,
asynch: true
};
if (self.options.zIndex) {
options.zIndex = self.zIndex;
}
//Take the layer and create a new MVTLayer or MVTPointLayer if one doesn't exist.
var layer = new MVTLayer(self, options).addTo(self.map);
return layer;
},
getLayers: function() {
return this.layers;
},
hideLayer: function(id) {
if (this.layers[id]) {
this._map.removeLayer(this.layers[id]);
if(this.options.visibleLayers.indexOf("id") > -1){
this.visibleLayers.splice(this.options.visibleLayers.indexOf("id"), 1);
}
}
},
showLayer: function(id) {
if (this.layers[id]) {
this._map.addLayer(this.layers[id]);
if(this.options.visibleLayers.indexOf("id") == -1){
this.visibleLayers.push(id);
}
}
//Make sure manager layer is always in front
this.bringToFront();
},
removeChildLayers: function(map){
//Remove child layers of this group layer
for (var key in this.layers) {
var layer = this.layers[key];
map.removeLayer(layer);
}
},
addChildLayers: function(map) {
var self = this;
if(self.options.visibleLayers.length > 0){
//only let thru the layers listed in the visibleLayers array
for(var i=0; i < self.options.visibleLayers.length; i++){
var layerName = self.options.visibleLayers[i];
var layer = this.layers[layerName];
if(layer){
//Proceed with parsing
map.addLayer(layer);
}
}
}else{
//Add all layers
for (var key in this.layers) {
var layer = this.layers[key];
// layer is set to visible and is not already on map
if (!layer._map) {
map.addLayer(layer);
}
}
}
},
bind: function(eventType, callback) {
this._eventHandlers[eventType] = callback;
},
_onClick: function(evt) {
//Here, pass the event on to the child MVTLayer and have it do the hit test and handle the result.
var self = this;
var onClick = self.options.onClick;
var clickableLayers = self.options.clickableLayers;
var layers = self.layers;
evt.tileID = getTileURL(evt.latlng.lat, evt.latlng.lng, this.map.getZoom());
// We must have an array of clickable layers, otherwise, we just pass
// the event to the public onClick callback in options.
if(!clickableLayers){
clickableLayers = Object.keys(self.layers);
}
if (clickableLayers && clickableLayers.length > 0) {
for (var i = 0, len = clickableLayers.length; i < len; i++) {
var key = clickableLayers[i];
var layer = layers[key];
if (layer) {
layer.handleClickEvent(evt, function(evt) {
if (typeof onClick === 'function') {
onClick(evt);
}
});
}
}
} else {
if (typeof onClick === 'function') {
onClick(evt);
}
}
},
setFilter: function(filterFunction, layerName) {
//take in a new filter function.
//Propagate to child layers.
//Add filter to all child layers if no layer is specified.
for (var key in this.layers) {
var layer = this.layers[key];
if (layerName){
if(key.toLowerCase() == layerName.toLowerCase()){
layer.options.filter = filterFunction; //Assign filter to child layer, only if name matches
//After filter is set, the old feature hashes are invalid. Clear them for next draw.
layer.clearLayerFeatureHash();
//layer.clearTileFeatureHash();
}
}
else{
layer.options.filter = filterFunction; //Assign filter to child layer
//After filter is set, the old feature hashes are invalid. Clear them for next draw.
layer.clearLayerFeatureHash();
//layer.clearTileFeatureHash();
}
}
},
/**
* Take in a new style function and propogate to child layers.
* If you do not set a layer name, it resets the style for all of the layers.
* @param styleFunction
* @param layerName
*/
setStyle: function(styleFn, layerName) {
for (var key in this.layers) {
var layer = this.layers[key];
if (layerName) {
if(key.toLowerCase() == layerName.toLowerCase()) {
layer.setStyle(styleFn);
}
} else {
layer.setStyle(styleFn);
}
}
},
featureSelected: function(mvtFeature) {
if (this.options.mutexToggle) {
if (this._selectedFeature) {
this._selectedFeature.deselect();
}
this._selectedFeature = mvtFeature;
}
if (this.options.onSelect) {
this.options.onSelect(mvtFeature);
}
},
featureDeselected: function(mvtFeature) {
if (this.options.mutexToggle && this._selectedFeature) {
this._selectedFeature = null;
}
if (this.options.onDeselect) {
this.options.onDeselect(mvtFeature);
}
},
_pbfLoaded: function() {
//Fires when all tiles from this layer have been loaded and drawn (or 404'd).
//Make sure manager layer is always in front
this.bringToFront();
//See if there is an event to execute
var self = this;
var onTilesLoaded = self.options.onTilesLoaded;
if (onTilesLoaded && typeof onTilesLoaded === 'function' && this._triggerOnTilesLoadedEvent === true) {
onTilesLoaded(this);
}
self._triggerOnTilesLoadedEvent = true; //reset - if redraw() is called with the optinal 'false' parameter to temporarily disable the onTilesLoaded event from firing. This resets it back to true after a single time of firing as 'false'.
}
});
if (typeof(Number.prototype.toRad) === "undefined") {
Number.prototype.toRad = function() {
return this * Math.PI / 180;
}
}
function getTileURL(lat, lon, zoom) {
var xtile = parseInt(Math.floor( (lon + 180) / 360 * (1<<zoom) ));
var ytile = parseInt(Math.floor( (1 - Math.log(Math.tan(lat.toRad()) + 1 / Math.cos(lat.toRad())) / Math.PI) / 2 * (1<<zoom) ));
return "" + zoom + ":" + xtile + ":" + ytile;
}
function tileLoaded(pbfSource, ctx) {
pbfSource.loadedTiles[ctx.id] = ctx;
}
function parseVT(vt){
for (var key in vt.layers) {
var lyr = vt.layers[key];
parseVTFeatures(lyr);
}
return vt;
}
function parseVTFeatures(vtl){
vtl.parsedFeatures = [];
var features = vtl._features;
for (var i = 0, len = features.length; i < len; i++) {
var vtf = vtl.feature(i);
vtf.coordinates = vtf.loadGeometry();
vtl.parsedFeatures.push(vtf);
}
return vtl;
}