├── LICENSE ├── README.md ├── images ├── search.png └── search_input.png └── src ├── leaflet.fusesearch.css └── leaflet.fusesearch.js /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Antoine Riche 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

leaflet-fusesearch

2 | 3 | Search features in a GeoJSON layer using the lightweight JavaScript fuzzy search Fuse.js 4 | 5 |

Usage

6 | 7 | First download Fuse.js from this repo or 8 | from Kiro's site, and load it in your page before leaflet-fusesearch.js.
9 |
10 | Create the control L.Control.FuseSearch and add it to the Map. 11 |
12 | var searchCtrl = L.control.fuseSearch()
13 | searchCtrl.addTo(map);
14 | 
15 | 16 | Then load your GeoJSON layer and index the features, choosing the properties you want to index 17 | (note you can pass either the FeatureCollection itself or its .features), e.g. 18 |
19 | searchCtrl.indexFeatures(jsonData, ['name', 'company', 'details']);
20 | 
21 | 22 | Finally you need to bind each layer (marker) to the feature it is associated with, so that selecting an item in the result list opens up the matching popup. For instance : 23 |
24 | L.geoJson(data, {
25 |     onEachFeature: function (feature, layer) {
26 |         feature.layer = layer;
27 |     }
28 | });
29 | 
30 | 31 | This is it ! By default the search control will appear on the top right corner of the map. 32 | This opens the search pane on the same side where you can type in the search string. 33 | The matching features are listed, with the indexed properties displayed. Clicking a feature 34 | on the list opens up the matching pop-up on the map, provided one is associated with it. 35 | 36 |

Options

37 | 38 | The FuseSearch control can be created with the following options : 39 | 50 |
51 |     showResultFct: function(feature, container) {
52 |         props = feature.properties;
53 |         var name = L.DomUtil.create('b', null, container);
54 |         name.innerHTML = props.name;
55 |         container.appendChild(L.DomUtil.create('br', null, container));
56 |         container.appendChild(document.createTextNode(props.details));
57 |     }
58 | 
59 | 60 | In addition these options are directly passed to Fuse - more details on Fuse.js : 61 | 66 | 67 |

Other functions

68 | The function refresh() can be used to search again the indexed features. 69 | This is useful for instance when some features have been filtered out, and saves you from reindexing the features. 70 | 71 |

Example

72 | 73 | I should really provide a simpler example, however for now have a look at my demo site. 74 | -------------------------------------------------------------------------------- /images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naomap/leaflet-fusesearch/1e356c69b2b77e76b8931ded3efe27992ea47a0c/images/search.png -------------------------------------------------------------------------------- /images/search_input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naomap/leaflet-fusesearch/1e356c69b2b77e76b8931ded3efe27992ea47a0c/images/search_input.png -------------------------------------------------------------------------------- /src/leaflet.fusesearch.css: -------------------------------------------------------------------------------- 1 | 2 | .leaflet-fusesearch-control { 3 | box-shadow: 0 1px 5px rgba(0,0,0,0.4); 4 | background: #fff; 5 | border-radius: 5px; 6 | } 7 | 8 | .leaflet-fusesearch-control .button { 9 | background-position: 50% 50%; 10 | background-repeat: no-repeat; 11 | background-image: url(../images/search.png); 12 | display: block; 13 | width: 36px; 14 | height: 36px; 15 | } 16 | .leaflet-touch .leaflet-fusesearch-control .button { 17 | width: 44px; 18 | height: 44px; 19 | } 20 | 21 | .leaflet-fusesearch-panel { 22 | position: absolute; 23 | height: 100%; 24 | width: 350px; 25 | 26 | z-index: -1; 27 | opacity: 0; 28 | -webkit-transition: opacity 0.3s linear; 29 | -moz-transition: opacity 0.3s linear; 30 | -o-transition: opacity 0.3s linear; 31 | transition: opacity 0.3s linear; 32 | 33 | -webkit-box-sizing: border-box; 34 | -moz-box-sizing: border-box; 35 | box-sizing: border-box; 36 | padding: 10px; 37 | } 38 | 39 | .leaflet-fusesearch-panel .content { 40 | height: 100%; 41 | width: 100%; 42 | 43 | overflow: auto; 44 | -webkit-overflow-scrolling: touch; 45 | 46 | -webkit-box-sizing: border-box; 47 | -moz-box-sizing: border-box; 48 | box-sizing: border-box; 49 | padding: 8px 20px; 50 | 51 | background: white; 52 | box-shadow: 0 1px 7px rgba(0,0,0,0.65); 53 | -webkit-border-radius: 6px; 54 | border-radius: 6px; 55 | 56 | color: black; 57 | font-size: 1.1em; 58 | } 59 | 60 | .leaflet-fusesearch-panel.left { 61 | left: 0; 62 | } 63 | 64 | .leaflet-fusesearch-panel.right { 65 | right: 0; 66 | } 67 | 68 | .leaflet-fusesearch-panel.visible { 69 | opacity: 1; 70 | z-index: 2000; 71 | } 72 | 73 | 74 | .leaflet-fusesearch-panel .close { 75 | position: absolute; 76 | right: 25px; 77 | top: 15px; 78 | width: 30px; 79 | height: 30px; 80 | color: #333; 81 | font-size: 25pt; 82 | line-height: 1em; 83 | text-align: center; 84 | background: white; 85 | -webkit-border-radius: 15px; 86 | border-radius: 15px; 87 | cursor: pointer; 88 | z-index: 8; 89 | } 90 | 91 | .leaflet-fusesearch-panel .search-image { 92 | background-repeat: no-repeat; 93 | background-image: url(../images/search_input.png); 94 | background-size: 20px 20px; 95 | background-position: 1px 1px; 96 | display: inline-block; 97 | position: relative; 98 | top: 4px; 99 | width: 22px; 100 | height: 22px; 101 | } 102 | 103 | .leaflet-fusesearch-panel .search-input { 104 | position: relative; 105 | top: 6px; 106 | left: 3px; 107 | } 108 | 109 | .leaflet-fusesearch-panel .result-item { 110 | color : black; 111 | } 112 | 113 | .leaflet-fusesearch-panel .result-item.clickable { 114 | cursor: pointer; 115 | } 116 | 117 | @media (max-width:320px) { 118 | .leaflet-fusesearch-panel { 119 | width: 100%; 120 | padding: 0; 121 | } 122 | .leaflet-fusesearch-panel .content { 123 | box-shadow: none; 124 | -webkit-border-radius: 0; 125 | border-radius: 0; 126 | } 127 | } 128 | 129 | @media (min-width: 321px) and (max-width: 480px) { 130 | .leaflet-fusesearch-panel { 131 | width: 250px; 132 | padding: 0; 133 | } 134 | } 135 | 136 | @media (min-width: 481px) and (max-width: 768px) { 137 | .leaflet-fusesearch-panel { 138 | width: 300px; 139 | } 140 | } 141 | 142 | @media (min-width: 769px) { 143 | .leaflet-fusesearch-panel { 144 | width: 350px; 145 | } 146 | } -------------------------------------------------------------------------------- /src/leaflet.fusesearch.js: -------------------------------------------------------------------------------- 1 | 2 | // From http://www.tutorialspoint.com/javascript/array_map.htm 3 | if (!Array.prototype.map) 4 | { 5 | Array.prototype.map = function(fun /*, thisp*/) 6 | { 7 | var len = this.length; 8 | if (typeof fun !== "function") 9 | throw new TypeError(); 10 | 11 | var res = new Array(len); 12 | var thisp = arguments[1]; 13 | for (var i = 0; i < len; i++) 14 | { 15 | if (i in this) 16 | res[i] = fun.call(thisp, this[i], i, this); 17 | } 18 | 19 | return res; 20 | }; 21 | } 22 | 23 | L.Control.FuseSearch = L.Control.extend({ 24 | 25 | includes: L.Evented.prototype, 26 | 27 | options: { 28 | position: 'topright', 29 | title: 'Search', 30 | panelTitle: '', 31 | placeholder: 'Search', 32 | caseSensitive: false, 33 | threshold: 0.5, 34 | maxResultLength: null, 35 | showResultFct: null, 36 | showInvisibleFeatures: true 37 | }, 38 | 39 | initialize: function(options) { 40 | L.setOptions(this, options); 41 | this._panelOnLeftSide = (this.options.position.indexOf("left") !== -1); 42 | }, 43 | 44 | onAdd: function(map) { 45 | 46 | var ctrl = this._createControl(); 47 | this._createPanel(map); 48 | this._setEventListeners(); 49 | map.invalidateSize(); 50 | 51 | return ctrl; 52 | }, 53 | 54 | onRemove: function(map) { 55 | 56 | this.hidePanel(map); 57 | this._clearEventListeners(); 58 | this._clearPanel(map); 59 | this._clearControl(); 60 | 61 | return this; 62 | }, 63 | 64 | _createControl: function() { 65 | 66 | var className = 'leaflet-fusesearch-control', 67 | container = L.DomUtil.create('div', className); 68 | 69 | // Control to open the search panel 70 | var butt = this._openButton = L.DomUtil.create('a', 'button', container); 71 | butt.href = '#'; 72 | butt.title = this.options.title; 73 | var stop = L.DomEvent.stopPropagation; 74 | L.DomEvent.on(butt, 'click', stop) 75 | .on(butt, 'mousedown', stop) 76 | .on(butt, 'touchstart', stop) 77 | .on(butt, 'mousewheel', stop) 78 | .on(butt, 'MozMousePixelScroll', stop); 79 | L.DomEvent.on(butt, 'click', L.DomEvent.preventDefault); 80 | L.DomEvent.on(butt, 'click', this.showPanel, this); 81 | 82 | return container; 83 | }, 84 | 85 | _clearControl: function() { 86 | // Unregister events to prevent memory leak 87 | var butt = this._openButton; 88 | var stop = L.DomEvent.stopPropagation; 89 | L.DomEvent.off(butt, 'click', stop) 90 | .off(butt, 'mousedown', stop) 91 | .off(butt, 'touchstart', stop) 92 | .off(butt, 'mousewheel', stop) 93 | .off(butt, 'MozMousePixelScroll', stop); 94 | L.DomEvent.off(butt, 'click', L.DomEvent.preventDefault); 95 | L.DomEvent.off(butt, 'click', this.showPanel); 96 | }, 97 | 98 | _createPanel: function(map) { 99 | var _this = this; 100 | 101 | // Create the search panel 102 | var mapContainer = map.getContainer(); 103 | var className = 'leaflet-fusesearch-panel', 104 | pane = this._panel = L.DomUtil.create('div', className, mapContainer); 105 | 106 | // Make sure we don't drag the map when we interact with the content 107 | var stop = L.DomEvent.stopPropagation; 108 | L.DomEvent.on(pane, 'click', stop) 109 | .on(pane, 'dblclick', stop) 110 | .on(pane, 'mousedown', stop) 111 | .on(pane, 'touchstart', stop) 112 | .on(pane, 'mousewheel', stop) 113 | .on(pane, 'MozMousePixelScroll', stop); 114 | 115 | // place the pane on the same side as the control 116 | if (this._panelOnLeftSide) { 117 | L.DomUtil.addClass(pane, 'left'); 118 | } else { 119 | L.DomUtil.addClass(pane, 'right'); 120 | } 121 | 122 | // Intermediate container to get the box sizing right 123 | var container = L.DomUtil.create('div', 'content', pane); 124 | 125 | var header = L.DomUtil.create('div', 'header', container); 126 | if (this.options.panelTitle) { 127 | var title = L.DomUtil.create('p', 'panel-title', header); 128 | title.innerHTML = this.options.panelTitle; 129 | } 130 | 131 | // Search image and input field 132 | L.DomUtil.create('img', 'search-image', header); 133 | this._input = L.DomUtil.create('input', 'search-input', header); 134 | this._input.maxLength = 30; 135 | this._input.placeholder = this.options.placeholder; 136 | this._input.onkeyup = function(evt) { 137 | var searchString = evt.currentTarget.value; 138 | _this.searchFeatures(searchString); 139 | }; 140 | 141 | // Close button 142 | var close = this._closeButton = L.DomUtil.create('a', 'close', header); 143 | close.innerHTML = '×'; 144 | L.DomEvent.on(close, 'click', this.hidePanel, this); 145 | 146 | // Where the result will be listed 147 | this._resultList = L.DomUtil.create('div', 'result-list', container); 148 | 149 | return pane; 150 | }, 151 | 152 | _clearPanel: function(map) { 153 | 154 | // Unregister event handlers 155 | var stop = L.DomEvent.stopPropagation; 156 | L.DomEvent.off(this._panel, 'click', stop) 157 | .off(this._panel, 'dblclick', stop) 158 | .off(this._panel, 'mousedown', stop) 159 | .off(this._panel, 'touchstart', stop) 160 | .off(this._panel, 'mousewheel', stop) 161 | .off(this._panel, 'MozMousePixelScroll', stop); 162 | 163 | L.DomEvent.off(this._closeButton, 'click', this.hidePanel); 164 | 165 | var mapContainer = map.getContainer(); 166 | mapContainer.removeChild(this._panel); 167 | 168 | this._panel = null; 169 | }, 170 | 171 | _setEventListeners : function() { 172 | var that = this; 173 | var input = this._input; 174 | this._map.addEventListener('overlayadd', function() { 175 | that.searchFeatures(input.value); 176 | }); 177 | this._map.addEventListener('overlayremove', function() { 178 | that.searchFeatures(input.value); 179 | }); 180 | }, 181 | 182 | _clearEventListeners: function() { 183 | this._map.removeEventListener('overlayadd'); 184 | this._map.removeEventListener('overlayremove'); 185 | }, 186 | 187 | isPanelVisible: function () { 188 | return L.DomUtil.hasClass(this._panel, 'visible'); 189 | }, 190 | 191 | showPanel: function () { 192 | if (! this.isPanelVisible()) { 193 | L.DomUtil.addClass(this._panel, 'visible'); 194 | // Preserve map centre 195 | this._map.panBy([this.getOffset() * 0.5, 0], {duration: 0.5}); 196 | this.fire('show'); 197 | this._input.select(); 198 | // Search again as visibility of features might have changed 199 | this.searchFeatures(this._input.value); 200 | } 201 | }, 202 | 203 | hidePanel: function (e) { 204 | if (this.isPanelVisible()) { 205 | L.DomUtil.removeClass(this._panel, 'visible'); 206 | // Move back the map centre - only if we still hold this._map 207 | // as this might already have been cleared up by removeFrom() 208 | if (null !== this._map) { 209 | this._map.panBy([this.getOffset() * -0.5, 0], {duration: 0.5}); 210 | }; 211 | this.fire('hide'); 212 | if(e) { 213 | L.DomEvent.stopPropagation(e); 214 | } 215 | } 216 | }, 217 | 218 | getOffset: function() { 219 | if (this._panelOnLeftSide) { 220 | return - this._panel.offsetWidth; 221 | } else { 222 | return this._panel.offsetWidth; 223 | } 224 | }, 225 | 226 | indexFeatures: function(data, keys) { 227 | 228 | var jsonFeatures = data.features || data; 229 | 230 | this._keys = keys; 231 | var properties = jsonFeatures.map(function(feature) { 232 | // Keep track of the original feature 233 | feature.properties._feature = feature; 234 | return feature.properties; 235 | }); 236 | 237 | var options = { 238 | keys: keys, 239 | caseSensitive: this.options.caseSensitive, 240 | threshold : this.options.threshold 241 | }; 242 | 243 | this._fuseIndex = new Fuse(properties, options); 244 | }, 245 | 246 | searchFeatures: function(string) { 247 | 248 | var result = this._fuseIndex.search(string); 249 | 250 | // Empty result list 251 | var listItems = document.querySelectorAll(".result-item"); 252 | for (var i = 0 ; i < listItems.length ; i++) { 253 | listItems[i].remove(); 254 | } 255 | 256 | var resultList = document.querySelector('.result-list'); 257 | var num = 0; 258 | var max = this.options.maxResultLength; 259 | for (var i in result) { 260 | var props = result[i]; 261 | var feature = props._feature; 262 | var popup = this._getFeaturePopupIfVisible(feature); 263 | 264 | if (undefined !== popup || this.options.showInvisibleFeatures) { 265 | this.createResultItem(props, resultList, popup); 266 | if (undefined !== max && ++num === max) 267 | break; 268 | } 269 | } 270 | }, 271 | 272 | refresh: function() { 273 | // Reapply the search on the indexed features - useful if features have been filtered out 274 | if (this.isPanelVisible()) { 275 | this.searchFeatures(this._input.value); 276 | } 277 | }, 278 | 279 | _getFeaturePopupIfVisible: function(feature) { 280 | var layer = feature.layer; 281 | if (undefined !== layer && this._map.hasLayer(layer)) { 282 | return layer.getPopup(); 283 | } 284 | }, 285 | 286 | createResultItem: function(props, container, popup) { 287 | 288 | var _this = this; 289 | var feature = props._feature; 290 | 291 | // Create a container and open the associated popup on click 292 | var resultItem = L.DomUtil.create('p', 'result-item', container); 293 | 294 | if (undefined !== popup) { 295 | L.DomUtil.addClass(resultItem, 'clickable'); 296 | resultItem.onclick = function() { 297 | 298 | if (window.matchMedia("(max-width:480px)").matches) { 299 | _this.hidePanel(); 300 | feature.layer.openPopup(); 301 | } else { 302 | _this._panAndPopup(feature, popup); 303 | } 304 | }; 305 | } 306 | 307 | // Fill in the container with the user-supplied function if any, 308 | // otherwise display the feature properties used for the search. 309 | if (null !== this.options.showResultFct) { 310 | this.options.showResultFct(feature, resultItem); 311 | } else { 312 | str = '' + props[this._keys[0]] + ''; 313 | for (var i = 1; i < this._keys.length; i++) { 314 | str += '
' + props[this._keys[i]]; 315 | } 316 | resultItem.innerHTML = str; 317 | }; 318 | 319 | return resultItem; 320 | }, 321 | 322 | _panAndPopup : function(feature, popup) { 323 | // Temporarily adapt the map padding so that the popup is not hidden by the search pane 324 | if (this._panelOnLeftSide) { 325 | var oldPadding = popup.options.autoPanPaddingTopLeft; 326 | var newPadding = new L.Point(- this.getOffset(), 10); 327 | popup.options.autoPanPaddingTopLeft = newPadding; 328 | feature.layer.openPopup(); 329 | popup.options.autoPanPaddingTopLeft = oldPadding; 330 | } else { 331 | var oldPadding = popup.options.autoPanPaddingBottomRight; 332 | var newPadding = new L.Point(this.getOffset(), 10); 333 | popup.options.autoPanPaddingBottomRight = newPadding; 334 | feature.layer.openPopup(); 335 | popup.options.autoPanPaddingBottomRight = oldPadding; 336 | } 337 | } 338 | }); 339 | 340 | L.control.fuseSearch = function(options) { 341 | return new L.Control.FuseSearch(options); 342 | }; 343 | --------------------------------------------------------------------------------