├── .eslintignore ├── .eslintrc ├── LICENSE ├── README.md ├── dist ├── mapbox-gl-layers.css └── mapbox-gl-layers.js ├── example.html ├── index.js └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard"], 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "browser": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Development Seed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Circle CI](https://circleci.com/gh/developmentseed/mapbox-gl-layers.svg?style=svg)](https://circleci.com/gh/developmentseed/mapbox-gl-layers) 2 | 3 | # mapbox-gl-layers 4 | 5 | Layer toggle for [Mapbox GL JS](https://www.mapbox.com/mapbox-gl-js/) 6 | 7 | ## Install 8 | 9 | `npm install mapbox-gl-layers` 10 | 11 | (Note the peer dependency on `mapbox-gl`!) 12 | 13 | ## Use 14 | 15 | ### CommonJS 16 | 17 | ```js 18 | var Layers = require('mapbox-gl-layers') 19 | 20 | new Layers({ 21 | layers: { 22 | 'ALL PARKS': ['national_park', 'parks'], 23 | 'National Parks': 'national_park', 24 | 'Other Parks': 'parks' 25 | } 26 | }).addTo(map) // map is the mapbox gl map instance 27 | ``` 28 | 29 | ### Standalone script 30 | 31 | Add to ``: 32 | 33 | ```html 34 | 35 | 36 | ``` 37 | 38 | And then: 39 | 40 | ```html 41 | 52 | ``` 53 | 54 | ## API 55 | 56 | ### Layers 57 | 58 | Creates a layer toggle control 59 | 60 | **Parameters** 61 | 62 | - `options` **[Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)=** 63 | - `options.type` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)=** Selection type: `multiple` to allow independently toggling each layer/group, `single` to only choose one at a time. (optional, default `'multiple'`) 64 | - `options.layers` **[Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)=** An object determining which layers to include. Each key is a display name (what's shown in the UI), and each value is the corresponding layer id in the map style (or an array of layer ids). 65 | - `options.position` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)=** A string indicating position on the map. Options are `top-right`, `top-left`, `bottom-right`, `bottom-left`. (optional, default `'top-right'`) 66 | - `options.onChange` **[function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function)=** Optional callback called with `{name: dispayName, layerIds: [...], active: true|false }` for the clicked layer 67 | 68 | **Examples** 69 | 70 | ```javascript 71 | (new Layers({ 'National Parks': 'national_park', 'Other Parks': 'parks' })) 72 | .addTo(map) 73 | ``` 74 | 75 | ## Contributing 76 | 77 | This is an [OPEN open source](http://openopensource.org/) project. 78 | Contributions are welcome! 79 | 80 | Steps: 81 | 82 | 1. Clone the repo and run `npm install`. 83 | 2. Start test server with `npm start`, open , 84 | and start make changes to `index.js` and friends. 85 | -------------------------------------------------------------------------------- /dist/mapbox-gl-layers.css: -------------------------------------------------------------------------------- 1 | .mapboxgl-layers { 2 | max-height: 100vh; 3 | overflow: scroll; 4 | } 5 | .mapboxgl-layers ul { 6 | background: #fff; 7 | margin: 0; 8 | padding: 10px; 9 | border-radius: 4px; 10 | list-style-type: none; 11 | } 12 | 13 | .mapboxgl-layers li { 14 | cursor: pointer; 15 | position: relative; 16 | margin: 0 5px; 17 | } 18 | .mapboxgl-layers li.active:before { 19 | position: absolute; 20 | right: 100%; 21 | content: '\2713 '; 22 | } 23 | .mapboxgl-layers li.partially-active:before { 24 | color: #ccc; 25 | } 26 | 27 | -------------------------------------------------------------------------------- /dist/mapbox-gl-layers.js: -------------------------------------------------------------------------------- 1 | (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.MapboxGLLayers = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o layer.id) 43 | if (!this.options.layers) { 44 | this.options.layers = {} 45 | 46 | // if there's Mapbox Studio metadata available, use any groups we can find 47 | var groups = {} 48 | if (style.metadata && style.metadata['mapbox:groups']) { 49 | groups = style.metadata['mapbox:groups'] 50 | Object.keys(groups).forEach((g) => { this.options.layers[groups[g].name] = [] }) 51 | } 52 | 53 | style.layers.forEach((layer) => { 54 | var group = layer.metadata ? groups[layer.metadata['mapbox:group']] : null 55 | if (layer.metadata && group) { 56 | this.options.layers[group.name].push(layer.id) 57 | } else { 58 | this.options.layers[layer.id] = [layer.id] 59 | } 60 | }) 61 | } 62 | this._map.on('style.change', this._update) 63 | this._map.style.on('layer.remove', this._update) 64 | this._map.style.on('layer.add', this._update) 65 | return this._render() 66 | } 67 | 68 | Layers.prototype.onRemove = function onRemove () { 69 | this._map.off('style.change', this._update) 70 | this._map.style.off('layer.remove', this._update) 71 | this._map.style.off('layer.add', this._update) 72 | } 73 | 74 | Layers.prototype._update = function _update () { 75 | this._allLayers = this._map.getStyle().layers.map((layer) => layer.id) 76 | yo.update(this._container, this._render()) 77 | } 78 | Layers.prototype._render = function _render () { 79 | var layers = this.options.layers 80 | var className = 'mapboxgl-ctrl mapboxgl-layers' 81 | return (function () { 82 | function appendChild (el, childs) { 83 | for (var i = 0; i < childs.length; i++) { 84 | var node = childs[i]; 85 | if (Array.isArray(node)) { 86 | appendChild(el, node) 87 | continue 88 | } 89 | if (typeof node === "number" || 90 | typeof node === "boolean" || 91 | node instanceof Date || 92 | node instanceof RegExp) { 93 | node = node.toString() 94 | } 95 | 96 | if (typeof node === "string") { 97 | if (el.lastChild && el.lastChild.nodeName === "#text") { 98 | el.lastChild.nodeValue += node 99 | continue 100 | } 101 | node = document.createTextNode(node) 102 | } 103 | 104 | if (node && node.nodeType) { 105 | el.appendChild(node) 106 | } 107 | } 108 | } 109 | var bel1 = document.createElement("div") 110 | bel1.setAttribute("class", arguments[1]) 111 | var bel0 = document.createElement("ul") 112 | appendChild(bel0, ["\n ",arguments[0],"\n "]) 113 | appendChild(bel1, ["\n ",bel0,"\n "]) 114 | return bel1 115 | }(Object.keys(layers) 116 | .filter((name) => layers[name].some(this._layerExists)) 117 | .map((name) => { 118 | var ids = layers[name].filter(this._layerExists) 119 | var className = ids.every(this._isActive) ? 'active' 120 | : ids.some(this._isActive) ? 'active partially-active' 121 | : '' 122 | return (function () { 123 | function appendChild (el, childs) { 124 | for (var i = 0; i < childs.length; i++) { 125 | var node = childs[i]; 126 | if (Array.isArray(node)) { 127 | appendChild(el, node) 128 | continue 129 | } 130 | if (typeof node === "number" || 131 | typeof node === "boolean" || 132 | node instanceof Date || 133 | node instanceof RegExp) { 134 | node = node.toString() 135 | } 136 | 137 | if (typeof node === "string") { 138 | if (el.lastChild && el.lastChild.nodeName === "#text") { 139 | el.lastChild.nodeValue += node 140 | continue 141 | } 142 | node = document.createTextNode(node) 143 | } 144 | 145 | if (node && node.nodeType) { 146 | el.appendChild(node) 147 | } 148 | } 149 | } 150 | var bel0 = document.createElement("li") 151 | bel0.setAttribute("data-layer-name", arguments[0]) 152 | bel0.setAttribute("data-layer-id", arguments[1]) 153 | bel0["onclick"] = arguments[2] 154 | bel0.setAttribute("class", arguments[3]) 155 | appendChild(bel0, ["\n ",arguments[4],"\n "]) 156 | return bel0 157 | }(name,ids.join(','),this._onClick,className,name)) 158 | }),className)) 159 | } 160 | 161 | Layers.prototype._onClick = function _onClick (e) { 162 | var ids = e.currentTarget.getAttribute('data-layer-id').split(',') 163 | .filter(this._layerExists) 164 | 165 | var activated = false 166 | if (this.options.type === 'single') { 167 | // single selection mode 168 | if (this._currentSelection) { 169 | this._currentSelection.forEach((id) => { 170 | this._map.setLayoutProperty(id, 'visibility', 'none') 171 | }) 172 | } 173 | // turn on any layer that IS in the selected group 174 | ids.forEach((id) => { 175 | this._map.setLayoutProperty(id, 'visibility', 'visible') 176 | }) 177 | this._currentSelection = ids 178 | activated = true 179 | } else { 180 | // 'toggle' mode 181 | var visibility = ids.some(this._isActive) ? 'none' : 'visible' 182 | ids.forEach((id) => { 183 | this._map.setLayoutProperty(id, 'visibility', visibility) 184 | }) 185 | activated = visibility === 'visible' 186 | } 187 | 188 | if (this.options.onChange) { 189 | this.options.onChange({ 190 | name: e.currentTarget.getAttribute('data-layer-name'), 191 | layerIds: ids, 192 | active: activated 193 | }) 194 | } 195 | } 196 | 197 | Layers.prototype._isActive = function isActive (id) { 198 | return this._map.getLayoutProperty(id, 'visibility') === 'visible' 199 | } 200 | 201 | Layers.prototype._layerExists = function (id) { 202 | return this._allLayers.indexOf(id) >= 0 203 | } 204 | 205 | 206 | },{"mapbox-gl/js/ui/control/control":2,"yo-yo":4}],2:[function(require,module,exports){ 207 | 'use strict'; 208 | 209 | module.exports = Control; 210 | 211 | /** 212 | * A base class for map-related interface elements. 213 | * 214 | * @class Control 215 | */ 216 | function Control() {} 217 | 218 | Control.prototype = { 219 | /** 220 | * Add this control to the map, returning the control itself 221 | * for chaining. This will insert the control's DOM element into 222 | * the map's DOM element if the control has a `position` specified. 223 | * 224 | * @param {Map} map 225 | * @returns {Control} `this` 226 | */ 227 | addTo: function(map) { 228 | this._map = map; 229 | var container = this._container = this.onAdd(map); 230 | if (this.options && this.options.position) { 231 | var pos = this.options.position; 232 | var corner = map._controlCorners[pos]; 233 | container.className += ' mapboxgl-ctrl'; 234 | if (pos.indexOf('bottom') !== -1) { 235 | corner.insertBefore(container, corner.firstChild); 236 | } else { 237 | corner.appendChild(container); 238 | } 239 | } 240 | 241 | return this; 242 | }, 243 | 244 | /** 245 | * Remove this control from the map it has been added to. 246 | * 247 | * @returns {Control} `this` 248 | */ 249 | remove: function() { 250 | this._container.parentNode.removeChild(this._container); 251 | if (this.onRemove) this.onRemove(this._map); 252 | this._map = null; 253 | return this; 254 | } 255 | }; 256 | 257 | },{}],3:[function(require,module,exports){ 258 | // Create a range object for efficently rendering strings to elements. 259 | var range; 260 | 261 | var testEl = typeof document !== 'undefined' ? document.body || document.createElement('div') : {}; 262 | 263 | // Fixes https://github.com/patrick-steele-idem/morphdom/issues/32 (IE7+ support) 264 | // <=IE7 does not support el.hasAttribute(name) 265 | var hasAttribute; 266 | if (testEl.hasAttribute) { 267 | hasAttribute = function hasAttribute(el, name) { 268 | return el.hasAttribute(name); 269 | }; 270 | } else { 271 | hasAttribute = function hasAttribute(el, name) { 272 | return el.getAttributeNode(name); 273 | }; 274 | } 275 | 276 | function empty(o) { 277 | for (var k in o) { 278 | if (o.hasOwnProperty(k)) { 279 | return false; 280 | } 281 | } 282 | 283 | return true; 284 | } 285 | function toElement(str) { 286 | if (!range && document.createRange) { 287 | range = document.createRange(); 288 | range.selectNode(document.body); 289 | } 290 | 291 | var fragment; 292 | if (range && range.createContextualFragment) { 293 | fragment = range.createContextualFragment(str); 294 | } else { 295 | fragment = document.createElement('body'); 296 | fragment.innerHTML = str; 297 | } 298 | return fragment.childNodes[0]; 299 | } 300 | 301 | var specialElHandlers = { 302 | /** 303 | * Needed for IE. Apparently IE doesn't think 304 | * that "selected" is an attribute when reading 305 | * over the attributes using selectEl.attributes 306 | */ 307 | OPTION: function(fromEl, toEl) { 308 | if ((fromEl.selected = toEl.selected)) { 309 | fromEl.setAttribute('selected', ''); 310 | } else { 311 | fromEl.removeAttribute('selected', ''); 312 | } 313 | }, 314 | /** 315 | * The "value" attribute is special for the element 316 | * since it sets the initial value. Changing the "value" 317 | * attribute without changing the "value" property will have 318 | * no effect since it is only used to the set the initial value. 319 | * Similar for the "checked" attribute. 320 | */ 321 | INPUT: function(fromEl, toEl) { 322 | fromEl.checked = toEl.checked; 323 | 324 | if (fromEl.value != toEl.value) { 325 | fromEl.value = toEl.value; 326 | } 327 | 328 | if (!hasAttribute(toEl, 'checked')) { 329 | fromEl.removeAttribute('checked'); 330 | } 331 | 332 | if (!hasAttribute(toEl, 'value')) { 333 | fromEl.removeAttribute('value'); 334 | } 335 | }, 336 | 337 | TEXTAREA: function(fromEl, toEl) { 338 | var newValue = toEl.value; 339 | if (fromEl.value != newValue) { 340 | fromEl.value = newValue; 341 | } 342 | 343 | if (fromEl.firstChild) { 344 | fromEl.firstChild.nodeValue = newValue; 345 | } 346 | } 347 | }; 348 | 349 | function noop() {} 350 | 351 | /** 352 | * Loop over all of the attributes on the target node and make sure the 353 | * original DOM node has the same attributes. If an attribute 354 | * found on the original node is not on the new node then remove it from 355 | * the original node 356 | * @param {HTMLElement} fromNode 357 | * @param {HTMLElement} toNode 358 | */ 359 | function morphAttrs(fromNode, toNode) { 360 | var attrs = toNode.attributes; 361 | var i; 362 | var attr; 363 | var attrName; 364 | var attrValue; 365 | var foundAttrs = {}; 366 | 367 | for (i=attrs.length-1; i>=0; i--) { 368 | attr = attrs[i]; 369 | if (attr.specified !== false) { 370 | attrName = attr.name; 371 | attrValue = attr.value; 372 | foundAttrs[attrName] = true; 373 | 374 | if (fromNode.getAttribute(attrName) !== attrValue) { 375 | fromNode.setAttribute(attrName, attrValue); 376 | } 377 | } 378 | } 379 | 380 | // Delete any extra attributes found on the original DOM element that weren't 381 | // found on the target element. 382 | attrs = fromNode.attributes; 383 | 384 | for (i=attrs.length-1; i>=0; i--) { 385 | attr = attrs[i]; 386 | if (attr.specified !== false) { 387 | attrName = attr.name; 388 | if (!foundAttrs.hasOwnProperty(attrName)) { 389 | fromNode.removeAttribute(attrName); 390 | } 391 | } 392 | } 393 | } 394 | 395 | /** 396 | * Copies the children of one DOM element to another DOM element 397 | */ 398 | function moveChildren(fromEl, toEl) { 399 | var curChild = fromEl.firstChild; 400 | while(curChild) { 401 | var nextChild = curChild.nextSibling; 402 | toEl.appendChild(curChild); 403 | curChild = nextChild; 404 | } 405 | return toEl; 406 | } 407 | 408 | function defaultGetNodeKey(node) { 409 | return node.id; 410 | } 411 | 412 | function morphdom(fromNode, toNode, options) { 413 | if (!options) { 414 | options = {}; 415 | } 416 | 417 | if (typeof toNode === 'string') { 418 | if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML') { 419 | var toNodeHtml = toNode; 420 | toNode = document.createElement('html'); 421 | toNode.innerHTML = toNodeHtml; 422 | } else { 423 | toNode = toElement(toNode); 424 | } 425 | } 426 | 427 | var savedEls = {}; // Used to save off DOM elements with IDs 428 | var unmatchedEls = {}; 429 | var getNodeKey = options.getNodeKey || defaultGetNodeKey; 430 | var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; 431 | var onNodeAdded = options.onNodeAdded || noop; 432 | var onBeforeElUpdated = options.onBeforeElUpdated || options.onBeforeMorphEl || noop; 433 | var onElUpdated = options.onElUpdated || noop; 434 | var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; 435 | var onNodeDiscarded = options.onNodeDiscarded || noop; 436 | var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || options.onBeforeMorphElChildren || noop; 437 | var childrenOnly = options.childrenOnly === true; 438 | var movedEls = []; 439 | 440 | function removeNodeHelper(node, nestedInSavedEl) { 441 | var id = getNodeKey(node); 442 | // If the node has an ID then save it off since we will want 443 | // to reuse it in case the target DOM tree has a DOM element 444 | // with the same ID 445 | if (id) { 446 | savedEls[id] = node; 447 | } else if (!nestedInSavedEl) { 448 | // If we are not nested in a saved element then we know that this node has been 449 | // completely discarded and will not exist in the final DOM. 450 | onNodeDiscarded(node); 451 | } 452 | 453 | if (node.nodeType === 1) { 454 | var curChild = node.firstChild; 455 | while(curChild) { 456 | removeNodeHelper(curChild, nestedInSavedEl || id); 457 | curChild = curChild.nextSibling; 458 | } 459 | } 460 | } 461 | 462 | function walkDiscardedChildNodes(node) { 463 | if (node.nodeType === 1) { 464 | var curChild = node.firstChild; 465 | while(curChild) { 466 | 467 | 468 | if (!getNodeKey(curChild)) { 469 | // We only want to handle nodes that don't have an ID to avoid double 470 | // walking the same saved element. 471 | 472 | onNodeDiscarded(curChild); 473 | 474 | // Walk recursively 475 | walkDiscardedChildNodes(curChild); 476 | } 477 | 478 | curChild = curChild.nextSibling; 479 | } 480 | } 481 | } 482 | 483 | function removeNode(node, parentNode, alreadyVisited) { 484 | if (onBeforeNodeDiscarded(node) === false) { 485 | return; 486 | } 487 | 488 | parentNode.removeChild(node); 489 | if (alreadyVisited) { 490 | if (!getNodeKey(node)) { 491 | onNodeDiscarded(node); 492 | walkDiscardedChildNodes(node); 493 | } 494 | } else { 495 | removeNodeHelper(node); 496 | } 497 | } 498 | 499 | function morphEl(fromEl, toEl, alreadyVisited, childrenOnly) { 500 | var toElKey = getNodeKey(toEl); 501 | if (toElKey) { 502 | // If an element with an ID is being morphed then it is will be in the final 503 | // DOM so clear it out of the saved elements collection 504 | delete savedEls[toElKey]; 505 | } 506 | 507 | if (!childrenOnly) { 508 | if (onBeforeElUpdated(fromEl, toEl) === false) { 509 | return; 510 | } 511 | 512 | morphAttrs(fromEl, toEl); 513 | onElUpdated(fromEl); 514 | 515 | if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { 516 | return; 517 | } 518 | } 519 | 520 | if (fromEl.tagName != 'TEXTAREA') { 521 | var curToNodeChild = toEl.firstChild; 522 | var curFromNodeChild = fromEl.firstChild; 523 | var curToNodeId; 524 | 525 | var fromNextSibling; 526 | var toNextSibling; 527 | var savedEl; 528 | var unmatchedEl; 529 | 530 | outer: while(curToNodeChild) { 531 | toNextSibling = curToNodeChild.nextSibling; 532 | curToNodeId = getNodeKey(curToNodeChild); 533 | 534 | while(curFromNodeChild) { 535 | var curFromNodeId = getNodeKey(curFromNodeChild); 536 | fromNextSibling = curFromNodeChild.nextSibling; 537 | 538 | if (!alreadyVisited) { 539 | if (curFromNodeId && (unmatchedEl = unmatchedEls[curFromNodeId])) { 540 | unmatchedEl.parentNode.replaceChild(curFromNodeChild, unmatchedEl); 541 | morphEl(curFromNodeChild, unmatchedEl, alreadyVisited); 542 | curFromNodeChild = fromNextSibling; 543 | continue; 544 | } 545 | } 546 | 547 | var curFromNodeType = curFromNodeChild.nodeType; 548 | 549 | if (curFromNodeType === curToNodeChild.nodeType) { 550 | var isCompatible = false; 551 | 552 | if (curFromNodeType === 1) { // Both nodes being compared are Element nodes 553 | if (curFromNodeChild.tagName === curToNodeChild.tagName) { 554 | // We have compatible DOM elements 555 | if (curFromNodeId || curToNodeId) { 556 | // If either DOM element has an ID then we handle 557 | // those differently since we want to match up 558 | // by ID 559 | if (curToNodeId === curFromNodeId) { 560 | isCompatible = true; 561 | } 562 | } else { 563 | isCompatible = true; 564 | } 565 | } 566 | 567 | if (isCompatible) { 568 | // We found compatible DOM elements so transform the current "from" node 569 | // to match the current target DOM node. 570 | morphEl(curFromNodeChild, curToNodeChild, alreadyVisited); 571 | } 572 | } else if (curFromNodeType === 3) { // Both nodes being compared are Text nodes 573 | isCompatible = true; 574 | // Simply update nodeValue on the original node to change the text value 575 | curFromNodeChild.nodeValue = curToNodeChild.nodeValue; 576 | } 577 | 578 | if (isCompatible) { 579 | curToNodeChild = toNextSibling; 580 | curFromNodeChild = fromNextSibling; 581 | continue outer; 582 | } 583 | } 584 | 585 | // No compatible match so remove the old node from the DOM and continue trying 586 | // to find a match in the original DOM 587 | removeNode(curFromNodeChild, fromEl, alreadyVisited); 588 | curFromNodeChild = fromNextSibling; 589 | } 590 | 591 | if (curToNodeId) { 592 | if ((savedEl = savedEls[curToNodeId])) { 593 | morphEl(savedEl, curToNodeChild, true); 594 | curToNodeChild = savedEl; // We want to append the saved element instead 595 | } else { 596 | // The current DOM element in the target tree has an ID 597 | // but we did not find a match in any of the corresponding 598 | // siblings. We just put the target element in the old DOM tree 599 | // but if we later find an element in the old DOM tree that has 600 | // a matching ID then we will replace the target element 601 | // with the corresponding old element and morph the old element 602 | unmatchedEls[curToNodeId] = curToNodeChild; 603 | } 604 | } 605 | 606 | // If we got this far then we did not find a candidate match for our "to node" 607 | // and we exhausted all of the children "from" nodes. Therefore, we will just 608 | // append the current "to node" to the end 609 | if (onBeforeNodeAdded(curToNodeChild) !== false) { 610 | fromEl.appendChild(curToNodeChild); 611 | onNodeAdded(curToNodeChild); 612 | } 613 | 614 | if (curToNodeChild.nodeType === 1 && (curToNodeId || curToNodeChild.firstChild)) { 615 | // The element that was just added to the original DOM may have 616 | // some nested elements with a key/ID that needs to be matched up 617 | // with other elements. We'll add the element to a list so that we 618 | // can later process the nested elements if there are any unmatched 619 | // keyed elements that were discarded 620 | movedEls.push(curToNodeChild); 621 | } 622 | 623 | curToNodeChild = toNextSibling; 624 | curFromNodeChild = fromNextSibling; 625 | } 626 | 627 | // We have processed all of the "to nodes". If curFromNodeChild is non-null then 628 | // we still have some from nodes left over that need to be removed 629 | while(curFromNodeChild) { 630 | fromNextSibling = curFromNodeChild.nextSibling; 631 | removeNode(curFromNodeChild, fromEl, alreadyVisited); 632 | curFromNodeChild = fromNextSibling; 633 | } 634 | } 635 | 636 | var specialElHandler = specialElHandlers[fromEl.tagName]; 637 | if (specialElHandler) { 638 | specialElHandler(fromEl, toEl); 639 | } 640 | } // END: morphEl(...) 641 | 642 | var morphedNode = fromNode; 643 | var morphedNodeType = morphedNode.nodeType; 644 | var toNodeType = toNode.nodeType; 645 | 646 | if (!childrenOnly) { 647 | // Handle the case where we are given two DOM nodes that are not 648 | // compatible (e.g.
--> or
--> TEXT) 649 | if (morphedNodeType === 1) { 650 | if (toNodeType === 1) { 651 | if (fromNode.tagName !== toNode.tagName) { 652 | onNodeDiscarded(fromNode); 653 | morphedNode = moveChildren(fromNode, document.createElement(toNode.tagName)); 654 | } 655 | } else { 656 | // Going from an element node to a text node 657 | morphedNode = toNode; 658 | } 659 | } else if (morphedNodeType === 3) { // Text node 660 | if (toNodeType === 3) { 661 | morphedNode.nodeValue = toNode.nodeValue; 662 | return morphedNode; 663 | } else { 664 | // Text node to something else 665 | morphedNode = toNode; 666 | } 667 | } 668 | } 669 | 670 | if (morphedNode === toNode) { 671 | // The "to node" was not compatible with the "from node" 672 | // so we had to toss out the "from node" and use the "to node" 673 | onNodeDiscarded(fromNode); 674 | } else { 675 | morphEl(morphedNode, toNode, false, childrenOnly); 676 | 677 | /** 678 | * What we will do here is walk the tree for the DOM element 679 | * that was moved from the target DOM tree to the original 680 | * DOM tree and we will look for keyed elements that could 681 | * be matched to keyed elements that were earlier discarded. 682 | * If we find a match then we will move the saved element 683 | * into the final DOM tree 684 | */ 685 | var handleMovedEl = function(el) { 686 | var curChild = el.firstChild; 687 | while(curChild) { 688 | var nextSibling = curChild.nextSibling; 689 | 690 | var key = getNodeKey(curChild); 691 | if (key) { 692 | var savedEl = savedEls[key]; 693 | if (savedEl && (curChild.tagName === savedEl.tagName)) { 694 | curChild.parentNode.replaceChild(savedEl, curChild); 695 | morphEl(savedEl, curChild, true /* already visited the saved el tree */); 696 | curChild = nextSibling; 697 | if (empty(savedEls)) { 698 | return false; 699 | } 700 | continue; 701 | } 702 | } 703 | 704 | if (curChild.nodeType === 1) { 705 | handleMovedEl(curChild); 706 | } 707 | 708 | curChild = nextSibling; 709 | } 710 | }; 711 | 712 | // The loop below is used to possibly match up any discarded 713 | // elements in the original DOM tree with elemenets from the 714 | // target tree that were moved over without visiting their 715 | // children 716 | if (!empty(savedEls)) { 717 | handleMovedElsLoop: 718 | while (movedEls.length) { 719 | var movedElsTemp = movedEls; 720 | movedEls = []; 721 | for (var i=0; i 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 |
19 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var yo = require('yo-yo') 2 | var Control = require('mapbox-gl/js/ui/control/control') 3 | 4 | module.exports = Layers 5 | 6 | /** 7 | * Creates a layer toggle control 8 | * @param {Object} [options] 9 | * @param {string} [options.type='multiple'] Selection type: `multiple` to allow independently toggling each layer/group, `single` to only choose one at a time. 10 | * @param {Object} [options.layers] An object determining which layers to include. Each key is a display name (what's shown in the UI), and each value is the corresponding layer id in the map style (or an array of layer ids). 11 | * @param {string} [options.position='top-right'] A string indicating position on the map. Options are `top-right`, `top-left`, `bottom-right`, `bottom-left`. 12 | * @param {function} [options.onChange] Optional callback called with `{name: dispayName, layerIds: [...], active: true|false }` for the clicked layer 13 | * @example 14 | * (new Layers({ 'National Parks': 'national_park', 'Other Parks': 'parks' })) 15 | * .addTo(map) 16 | */ 17 | function Layers (options) { 18 | this.options = Object.assign({}, this.options, options) 19 | if (options.layers) { 20 | // normalize layers to arrays 21 | var layers = {} 22 | for (var k in this.options.layers) { 23 | layers[k] = Array.isArray(this.options.layers[k]) 24 | ? this.options.layers[k] : [this.options.layers[k]] 25 | } 26 | this.options.layers = layers 27 | } 28 | 29 | this._onClick = this._onClick.bind(this) 30 | this._isActive = this._isActive.bind(this) 31 | this._layerExists = this._layerExists.bind(this) 32 | this._update = this._update.bind(this) 33 | } 34 | 35 | Layers.prototype = Object.create(Control.prototype) 36 | Layers.prototype.constructor = Layers 37 | Layers.prototype.options = { position: 'top-right', type: 'multiple' } 38 | Layers.prototype.onAdd = function onAdd (map) { 39 | this._map = map 40 | var style = map.getStyle() 41 | this._allLayers = style.layers.map((layer) => layer.id) 42 | if (!this.options.layers) { 43 | this.options.layers = {} 44 | 45 | // if there's Mapbox Studio metadata available, use any groups we can find 46 | var groups = {} 47 | if (style.metadata && style.metadata['mapbox:groups']) { 48 | groups = style.metadata['mapbox:groups'] 49 | Object.keys(groups).forEach((g) => { this.options.layers[groups[g].name] = [] }) 50 | } 51 | 52 | style.layers.forEach((layer) => { 53 | var group = layer.metadata ? groups[layer.metadata['mapbox:group']] : null 54 | if (layer.metadata && group) { 55 | this.options.layers[group.name].push(layer.id) 56 | } else { 57 | this.options.layers[layer.id] = [layer.id] 58 | } 59 | }) 60 | } 61 | this._map.on('style.change', this._update) 62 | this._map.style.on('layer.remove', this._update) 63 | this._map.style.on('layer.add', this._update) 64 | return this._render() 65 | } 66 | 67 | Layers.prototype.onRemove = function onRemove () { 68 | this._map.off('style.change', this._update) 69 | this._map.style.off('layer.remove', this._update) 70 | this._map.style.off('layer.add', this._update) 71 | } 72 | 73 | Layers.prototype._update = function _update () { 74 | this._allLayers = this._map.getStyle().layers.map((layer) => layer.id) 75 | yo.update(this._container, this._render()) 76 | } 77 | Layers.prototype._render = function _render () { 78 | var layers = this.options.layers 79 | var className = 'mapboxgl-ctrl mapboxgl-layers' 80 | return yo` 81 |
82 |
    83 | ${Object.keys(layers) 84 | .filter((name) => layers[name].some(this._layerExists)) 85 | .map((name) => { 86 | var ids = layers[name].filter(this._layerExists) 87 | var className = ids.every(this._isActive) ? 'active' 88 | : ids.some(this._isActive) ? 'active partially-active' 89 | : '' 90 | return yo` 91 |
  • 92 | ${name} 93 |
  • ` 94 | })} 95 |
96 |
97 | ` 98 | } 99 | 100 | Layers.prototype._onClick = function _onClick (e) { 101 | var ids = e.currentTarget.getAttribute('data-layer-id').split(',') 102 | .filter(this._layerExists) 103 | 104 | var activated = false 105 | if (this.options.type === 'single') { 106 | // single selection mode 107 | if (this._currentSelection) { 108 | this._currentSelection.forEach((id) => { 109 | this._map.setLayoutProperty(id, 'visibility', 'none') 110 | }) 111 | } 112 | // turn on any layer that IS in the selected group 113 | ids.forEach((id) => { 114 | this._map.setLayoutProperty(id, 'visibility', 'visible') 115 | }) 116 | this._currentSelection = ids 117 | activated = true 118 | } else { 119 | // 'toggle' mode 120 | var visibility = ids.some(this._isActive) ? 'none' : 'visible' 121 | ids.forEach((id) => { 122 | this._map.setLayoutProperty(id, 'visibility', visibility) 123 | }) 124 | activated = visibility === 'visible' 125 | } 126 | 127 | if (this.options.onChange) { 128 | this.options.onChange({ 129 | name: e.currentTarget.getAttribute('data-layer-name'), 130 | layerIds: ids, 131 | active: activated 132 | }) 133 | } 134 | } 135 | 136 | Layers.prototype._isActive = function isActive (id) { 137 | return this._map.getLayoutProperty(id, 'visibility') === 'visible' 138 | } 139 | 140 | Layers.prototype._layerExists = function (id) { 141 | return this._allLayers.indexOf(id) >= 0 142 | } 143 | 144 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mapbox-gl-layers", 3 | "version": "1.2.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "budo dist/mapbox-gl-layers:index.js -- --standalone MapboxGLLayers -g yo-yoify", 8 | "test": "eslint .", 9 | "bundle": "browserify --standalone MapboxGLLayers -g yo-yoify index.js > dist/mapbox-gl-layers.js", 10 | "docs": "documentation readme -s API" 11 | }, 12 | "keywords": [ 13 | "mapbox", 14 | "mapbox-gl", 15 | "plugin" 16 | ], 17 | "author": "Anand Thakker (http://anandthakker.net/)", 18 | "license": "MIT", 19 | "peerDependencies": { 20 | "mapbox-gl": "^0.17.0" 21 | }, 22 | "devDependencies": { 23 | "browserify": "^13.0.0", 24 | "budo": "^8.2.1", 25 | "documentation": "^4.0.0-beta2", 26 | "eslint": "^2.7.0", 27 | "eslint-config-standard": "^5.1.0", 28 | "eslint-plugin-promise": "^1.1.0", 29 | "eslint-plugin-standard": "^1.3.2", 30 | "mapbox-gl": "^0.17.0", 31 | "yo-yo": "^1.1.1", 32 | "yo-yoify": "^1.0.3" 33 | } 34 | } 35 | --------------------------------------------------------------------------------