UNPKG

leaflet-mapbox-vector-tile

Version:

A Leaflet Plugin that renders Mapbox Vector Tiles on HTML5 Canvas.

527 lines (440 loc) 15.4 kB
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; }