├── 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 |
40 | position
: position of the control, the search pane shows on the matching side. Default 'topright'
.
41 | title
: used for the control tooltip, default 'Search'
42 | panelTitle
: title string to add on the top of the search panel, none by default
43 | placeholder
: used for the input placeholder, default 'Search'
44 | maxResultLength
: number of features displayed in the result list, default is null
45 | and all features found by Fuse are displayed
46 | showInvisibleFeatures
: display the matching features even if their layer is invisible, default true
47 | showResultFct
: function to display a feature returned by the search, parameters are the
48 | feature and an HTML container. Here is an example :
49 |
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 |
62 | caseSensitive
: whether comparisons should be case sensitive, default is false
63 | threshold
: a decimal value indicating at which point the match algorithm gives up.
64 | A threshold of 0.0 requires a perfect match, a threshold of 1.0 would match anything, default 0.5
65 |
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 |
--------------------------------------------------------------------------------