From 47199c630ec528a6e1e0e6b66cc0f2147d9979f9 Mon Sep 17 00:00:00 2001 From: Henry Tung Date: Fri, 31 Jul 2015 21:52:58 -0700 Subject: [PATCH 1/3] Modify visibleLayers to allow for mapping. - visibleLayers can now be specified as a { [string]: string } map. This allows specification of multiple, differently-styled render passes based on the same layer data. Is particularly useful for rendering overlay-style interactive elements (like hover). - Empty visibleLayers array now means no layers, instead of all layers shown. Slightly API-breaking, though this seems like the more consistent value (probably better to treat absent/null as the special "show all" value, rather than empty array, which logically seems more like "show none". --- src/MVTSource.js | 81 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/src/MVTSource.js b/src/MVTSource.js index e405a82..7e50ef1 100644 --- a/src/MVTSource.js +++ b/src/MVTSource.js @@ -12,7 +12,7 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ url: "", //URL TO Vector Tile Source, getIDForLayerFeature: function() {}, tileSize: 256, - visibleLayers: [] + visibleLayers: null }, layers: {}, //Keep a list of the layers contained in the PBFs processedTiles: {}, //Keep a list of tiles that have been processed already @@ -83,7 +83,7 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ */ this.zIndex = options.zIndex; - if (typeof options.style === 'function') { + if (typeof options.style === 'function' || typeof options.style === 'object') { this.style = options.style; } @@ -212,9 +212,14 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ 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()); + // Check the attachment status of the layer. + if (!self.map) { + console.log("Fetched tile for removed map."); + return; + } + // 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.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); @@ -247,19 +252,23 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ 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); - } + var visibleLayers = self.options.visibleLayers; + if (!visibleLayers) { + visibleLayers = Object.keys(vt.layers); + } + + var layerMapping = visibleLayers; + if (Array.isArray(visibleLayers)) { + layerMapping = {}; + for (var i=0; i < visibleLayers.length; i++) { + layerMapping[visibleLayers[i]] = visibleLayers[i]; } - }else{ - //Parse all vt.layers - for (var key in vt.layers) { - self.prepareMVTLayers(vt.layers[key], key, ctx, parsed); + } + + for (var key in layerMapping) { + var lyr = vt.layers[layerMapping[key]]; + if (lyr) { + self.prepareMVTLayers(lyr, key, ctx, parsed); } } }, @@ -291,11 +300,16 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ getIDForLayerFeature = Util.getIDForLayerFeature; } + var style = self.style; + if (typeof style === 'object') { + style = style[key]; + } + var options = { getIDForLayerFeature: getIDForLayerFeature, filter: self.options.filter, layerOrdering: self.options.layerOrdering, - style: self.style, + style: style, name: key, asynch: true }; @@ -317,8 +331,13 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ 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); + var visibleLayers = this.options.visibleLayers; + if (visibleLayers) { + if (Array.isArray(visibleLayers) && visibleLayers.indexOf(id) > -1) { + visibleLayers.splice(visibleLayers.indexOf(id), 1); + } else { + delete visibleLayers[id]; + } } } }, @@ -326,8 +345,13 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ showLayer: function(id) { if (this.layers[id]) { this._map.addLayer(this.layers[id]); - if(this.options.visibleLayers.indexOf("id") == -1){ - this.visibleLayers.push(id); + var visibleLayers = this.options.visibleLayers; + if (visibleLayers) { + if (Array.isArray(visibleLayers)) { + visibleLayers.push(id); + } else { + visibleLayers[id] = id; + } } } //Make sure manager layer is always in front @@ -343,11 +367,14 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ }, 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 visibleLayers = this.visibleLayers; + if (visibleLayers) { + //only let thru the layers listed in the visibleLayers array or object + if (!Array.isArray(visibleLayers)) { + visibleLayers = Object.keys(visibleLayers); + } + for(var i=0; i < visibleLayers.length; i++){ + var layerName = visibleLayers[i]; var layer = this.layers[layerName]; if(layer){ //Proceed with parsing From 8d47b111da31028d4f32f413396304256f878b4c Mon Sep 17 00:00:00 2001 From: Henry Tung Date: Tue, 4 Aug 2015 13:34:41 -0700 Subject: [PATCH 2/3] Expose method to redraw features individually This allows individual MVTFeatures to be restyled by setting the style on them, then redrawing just the tiles containing the feature (rather than mutating one piece of the style and redrawing the whole layer). This allows for fast hover interaction. --- src/MVTFeature.js | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/MVTFeature.js b/src/MVTFeature.js index 16d741b..6cbb483 100755 --- a/src/MVTFeature.js +++ b/src/MVTFeature.js @@ -87,7 +87,7 @@ function ajaxCallback(self, response) { } self._setStyle(self.mvtLayer.style); - redrawTiles(self); + this.redraw(); } MVTFeature.prototype._setStyle = function(styleFn) { @@ -195,20 +195,15 @@ MVTFeature.prototype.clearTileFeatures = function(zoom) { /** * Redraws all of the tiles associated with a feature. Useful for * style change and toggling. - * - * @param self */ -function redrawTiles(self) { +MVTFeature.prototype.redraw = function() { //Redraw the whole tile, not just this vtf - var tiles = self.tiles; - var mvtLayer = self.mvtLayer; - - for (var id in tiles) { + for (var id in this.tiles) { var tileZoom = parseInt(id.split(':')[0]); - var mapZoom = self.map.getZoom(); + var mapZoom = this.map.getZoom(); if (tileZoom === mapZoom) { //Redraw the tile - mvtLayer.redrawTile(id); + this.mvtLayer.redrawTile(id); } } } @@ -224,7 +219,7 @@ MVTFeature.prototype.toggle = function() { MVTFeature.prototype.select = function() { this.selected = true; this.mvtSource.featureSelected(this); - redrawTiles(this); + this.redraw(); var linkedFeature = this.linkedFeature(); if (linkedFeature && linkedFeature.staticLabel && !linkedFeature.staticLabel.selected) { linkedFeature.staticLabel.select(); @@ -234,7 +229,7 @@ MVTFeature.prototype.select = function() { MVTFeature.prototype.deselect = function() { this.selected = false; this.mvtSource.featureDeselected(this); - redrawTiles(this); + this.redraw(); var linkedFeature = this.linkedFeature(); if (linkedFeature && linkedFeature.staticLabel && linkedFeature.staticLabel.selected) { linkedFeature.staticLabel.deselect(); From 6244a7e23bf7e5b859af408cbb92a518cf868ba8 Mon Sep 17 00:00:00 2001 From: Henry Tung Date: Tue, 18 Aug 2015 00:29:22 -0700 Subject: [PATCH 3/3] Add spatial index for fast hover Brought in rbush to accelerate featureAt at high feature scale. This makes it a viable replacement for UtfGrid in hover interaction. Didn't actually implement hover styling here, since I opted to provide custom styling using a combination of mouseover/move/out listeners on the layer and featureAt, as well as the overlay-drawing code from before. --- package.json | 5 ++- src/MVTFeature.js | 8 +++- src/MVTLayer.js | 99 ++++++++++++++++++++++++++++------------------- src/MVTSource.js | 70 +++++++++++++++++++++------------ 4 files changed, 114 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index ef70259..20448a1 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,11 @@ }, "homepage": "https://github.com/SpatialServer/Leaflet.MapboxVectorTile", "dependencies": { - "vector-tile": "~0.1.2", "pbf": "0.0.2", "point-geometry": "0.0.0", - "request": "~2.44.0" + "rbush": "^1.4.0", + "request": "~2.44.0", + "vector-tile": "~0.1.2" }, "devDependencies": { "browserify": "~5.9.1", diff --git a/src/MVTFeature.js b/src/MVTFeature.js index 6cbb483..9f23b37 100755 --- a/src/MVTFeature.js +++ b/src/MVTFeature.js @@ -156,8 +156,12 @@ MVTFeature.prototype.draw = function(canvasID) { }; MVTFeature.prototype.getPathsForTile = function(canvasID) { - //Get the info from the parts list - return this.tiles[canvasID].paths; + var tile = this.tiles[canvasID]; + if (tile) { + return tile.paths; + } else { + return []; + } }; MVTFeature.prototype.addTileFeature = function(vtf, ctx) { diff --git a/src/MVTLayer.js b/src/MVTLayer.js index 9723ca3..b76c2c6 100755 --- a/src/MVTLayer.js +++ b/src/MVTLayer.js @@ -4,6 +4,7 @@ /** Forked from https://gist.github.com/DGuidi/1716010 **/ var MVTFeature = require('./MVTFeature'); var Util = require('./MVTUtil'); +var rbush = require('rbush'); module.exports = L.TileLayer.Canvas.extend({ @@ -103,9 +104,11 @@ module.exports = L.TileLayer.Canvas.extend({ }, _initializeFeaturesHash: function(ctx){ - this._canvasIDToFeatures[ctx.id] = {}; - this._canvasIDToFeatures[ctx.id].features = []; - this._canvasIDToFeatures[ctx.id].canvas = ctx.canvas; + this._canvasIDToFeatures[ctx.id] = { + features: [], + canvas: ctx.canvas, + index: rbush(9) + }; }, _draw: function(ctx) { @@ -154,8 +157,6 @@ module.exports = L.TileLayer.Canvas.extend({ //See if we can pluck the child tile from this PBF tile layer based on the master layer's tile id. layerCtx.canvas = self._tiles[tilePoint.x + ":" + tilePoint.y]; - - //Initialize this tile's feature storage hash, if it hasn't already been created. Used for when filters are updated, and features are cleared to prepare for a fresh redraw. if (!this._canvasIDToFeatures[layerCtx.id]) { this._initializeFeaturesHash(layerCtx); @@ -165,6 +166,7 @@ module.exports = L.TileLayer.Canvas.extend({ } var features = vtl.parsedFeatures; + var toIndex = []; for (var i = 0, len = features.length; i < len; i++) { var vtf = features[i]; //vector tile feature vtf.layer = vtl; @@ -187,6 +189,12 @@ module.exports = L.TileLayer.Canvas.extend({ var uniqueID = self.options.getIDForLayerFeature(vtf) || i; var mvtFeature = self.features[uniqueID]; + /** + * Index the feature by bounding box into rbush. + */ + var box = bbox(vtf, layerCtx.tileSize, uniqueID); + toIndex.push(box); + /** * Use layerOrdering function to apply a zIndex property to each vtf. This is defined in * TileLayer.MVTSource.js. Used below to sort features.npm @@ -212,9 +220,10 @@ module.exports = L.TileLayer.Canvas.extend({ } //Associate & Save this feature with this tile for later - if(layerCtx && layerCtx.id) self._canvasIDToFeatures[layerCtx.id]['features'].push(mvtFeature); + self._canvasIDToFeatures[layerCtx.id].features.push(mvtFeature); } + self._canvasIDToFeatures[layerCtx.id].index.load(toIndex); /** * Apply sorting (zIndex) on feature if there is a function defined in the options object @@ -287,25 +296,22 @@ module.exports = L.TileLayer.Canvas.extend({ this.setLowestCount(count); }, - //This is the old way. It works, but is slow for mouseover events. Fine for click events. - handleClickEvent: function(evt, cb) { - //Click happened on the GroupLayer (Manager) and passed it here - var tileID = evt.tileID.split(":").slice(1, 3).join(":"); - var zoom = evt.tileID.split(":")[0]; - var canvas = this._tiles[tileID]; - if(!canvas) (cb(evt)); //break out - var x = evt.layerPoint.x - canvas._leaflet_pos.x; - var y = evt.layerPoint.y - canvas._leaflet_pos.y; + featureAt: function(tileID, tilePixelPoint) { + if (!this._canvasIDToFeatures[tileID]) return null; // break out + + var zoom = tileID.split(":")[0]; + var x = tilePixelPoint.x; + var y = tilePixelPoint.y; - var tilePoint = {x: x, y: y}; - var features = this._canvasIDToFeatures[evt.tileID].features; + var index = this._canvasIDToFeatures[tileID].index; var minDistance = Number.POSITIVE_INFINITY; var nearest = null; var j, paths, distance; - for (var i = 0; i < features.length; i++) { - var feature = features[i]; + var matches = index.search([x, y, x, y]); + for (var i = 0; i < matches.length; i++) { + var feature = this.features[matches[i].id]; switch (feature.type) { case 1: //Point - currently rendered as circular paths. Intersect with that. @@ -319,7 +325,7 @@ module.exports = L.TileLayer.Canvas.extend({ radius = feature.style.radius; } - paths = feature.getPathsForTile(evt.tileID); + paths = feature.getPathsForTile(tileID); for (j = 0; j < paths.length; j++) { //Builds a circle of radius feature.style.radius (assuming circular point symbology). if(in_circle(paths[j][0].x, paths[j][0].y, radius, x, y)){ @@ -330,10 +336,10 @@ module.exports = L.TileLayer.Canvas.extend({ break; case 2: //LineString - paths = feature.getPathsForTile(evt.tileID); + paths = feature.getPathsForTile(tileID); for (j = 0; j < paths.length; j++) { if (feature.style) { - var distance = this._getDistanceFromLine(tilePoint, paths[j]); + var distance = this._getDistanceFromLine(tilePixelPoint, paths[j]); var thickness = (feature.selected && feature.style.selected ? feature.style.selected.size : feature.style.size); if (distance < thickness / 2 + this.options.lineClickTolerance && distance < minDistance) { nearest = feature; @@ -344,9 +350,9 @@ module.exports = L.TileLayer.Canvas.extend({ break; case 3: //Polygon - paths = feature.getPathsForTile(evt.tileID); + paths = feature.getPathsForTile(tileID); for (j = 0; j < paths.length; j++) { - if (this._isPointInPoly(tilePoint, paths[j])) { + if (this._isPointInPoly(tilePixelPoint, paths[j])) { nearest = feature; minDistance = 0; // point is inside the polygon, so distance is zero } @@ -356,11 +362,7 @@ module.exports = L.TileLayer.Canvas.extend({ if (minDistance == 0) break; } - if (nearest && nearest.toggleEnabled) { - nearest.toggle(); - } - evt.feature = nearest; - cb(evt); + return nearest; }, clearTile: function(id) { @@ -377,8 +379,10 @@ module.exports = L.TileLayer.Canvas.extend({ context.clearRect(0, 0, canvas.width, canvas.height); }, - clearTileFeatureHash: function(canvasID){ - this._canvasIDToFeatures[canvasID] = { features: []}; //Get rid of all saved features + clearTileFeatureHash: function(canvasID) { + // Get rid of all saved features + this._canvasIDToFeatures[canvasID].features = []; + this._canvasIDToFeatures[canvasID].index = rbush(9); }, clearLayerFeatureHash: function(){ @@ -420,14 +424,6 @@ module.exports = L.TileLayer.Canvas.extend({ } }, - _resetCanvasIDToFeatures: function(canvasID, canvas) { - - this._canvasIDToFeatures[canvasID] = {}; - this._canvasIDToFeatures[canvasID].features = []; - this._canvasIDToFeatures[canvasID].canvas = canvas; - - }, - linkedLayer: function() { if(this.mvtSource.layerLink) { var linkName = this.mvtSource.layerLink(this.name); @@ -444,6 +440,29 @@ module.exports = L.TileLayer.Canvas.extend({ }); +function bbox(vtf, tileSize, id) { + var divisor = vtf.extent / tileSize; + + var minX = Number.POSITIVE_INFINITY; + var maxX = Number.NEGATIVE_INFINITY; + var minY = Number.POSITIVE_INFINITY; + var maxY = Number.NEGATIVE_INFINITY; + vtf.coordinates.forEach(function(coordinates) { + coordinates.forEach(function(coordinate) { + var x = coordinate.x / divisor; + var y = coordinate.y / divisor; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + }); + }); + + var box = [minX, minY, maxX, maxY]; + box.id = id; + return box; +} + function removeLabels(self) { var features = self.featuresWithLabels; @@ -494,4 +513,4 @@ function waitFor(testFx, onReady, timeOutMillis) { } } }, 50); //< repeat check every 50ms -}; \ No newline at end of file +}; diff --git a/src/MVTSource.js b/src/MVTSource.js index 7e50ef1..50229dc 100644 --- a/src/MVTSource.js +++ b/src/MVTSource.js @@ -397,6 +397,28 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ this._eventHandlers[eventType] = callback; }, + featureAtLatLng: function(latlng) { + return this.featureAtContainerPoint(this.map.latLngToContainerPoint(latlng)); + }, + + featureAtContainerPoint: function(containerPoint) { + return this._featureAt(containerPoint, this.layers); + }, + + _featureAt: function(containerPoint, layers) { + var tilePoint = this._getTilePoint(containerPoint); + + // TODO: Z-ordering? Clickable? + for (var key in layers) { + var layer = layers[key]; + var feature = layer.featureAt(tilePoint.tileID, tilePoint); + if (feature) { + return feature; + } + } + return null; + }, + _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; @@ -404,33 +426,28 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ 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) { + if (clickableLayers) { + layers = {}; for (var i = 0, len = clickableLayers.length; i < len; i++) { var key = clickableLayers[i]; - var layer = layers[key]; + var layer = self.layers[key]; if (layer) { - layer.handleClickEvent(evt, function(evt) { - if (typeof onClick === 'function') { - onClick(evt); - } - }); + layers[key] = layer; } } - } else { - if (typeof onClick === 'function') { - onClick(evt); - } } + var feature = this._featureAt(evt.layerPoint, layers); + if (feature && feature.toggleEnabled) { + feature.toggle(); + } + + evt.feature = feature; + if (typeof onClick === 'function') { + onClick(evt); + } }, setFilter: function(filterFunction, layerName) { @@ -512,6 +529,17 @@ module.exports = L.TileLayer.MVTSource = L.TileLayer.Canvas.extend({ 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'. + }, + + _getTilePoint: function(containerPoint) { + var tileSize = this.options.tileSize; + var globalPoint = this.map.containerPointToLayerPoint(containerPoint) + .add(this.map.getPixelOrigin()); + + var tileIndexPoint = globalPoint.divideBy(tileSize).floor(); + var tilePoint = globalPoint.subtract(tileIndexPoint.multiplyBy(tileSize)); + tilePoint.tileID = "" + this.map.getZoom() + ":" + tileIndexPoint.x + ":" + tileIndexPoint.y; + return tilePoint; } }); @@ -523,12 +551,6 @@ if (typeof(Number.prototype.toRad) === "undefined") { } } -function getTileURL(lat, lon, zoom) { - var xtile = parseInt(Math.floor( (lon + 180) / 360 * (1<