/*
* Leaflet Search Control 1.4.6
* Copyright 2013, Stefano Cudini - http://labs.easyblog.it/stefano-cudini/
* Licensed under the MIT license.
*
* Demo:
* http://labs.easyblog.it/maps/leaflet-search
*
* Repositories:
* https://github.com/stefanocudini/leaflet-search
* https://bitbucket.org/zakis_/leaflet-search
*
*/
(function() {
L.Control.Search = L.Control.extend({
includes: L.Mixin.Events,
//
// Name Data passed Description
//
//Managed Events:
// search_locationfound {latlng, title} fired after moved and show markerLocation
// search_collapsed {} fired after control was collapsed
//
//Public methods:
// setLayer() L.LayerGroup() set layer search at runtime
// showAlert() 'Text message' Show alert message
//
options: {
url: '', //url for search by ajax request, ex: "search.php?q={s}"
jsonpParam: null, //jsonp param name for search by jsonp service, ex: "callback"
layer: null, //layer where search markers(is a L.LayerGroup)
callData: null, //function that fill _recordsCache, passed searching text by first param and callback in second
//TODO important! implements uniq option 'sourceData' that recognizes source type: url,array,callback or layer
//TODO implement can do research on multiple sources
propertyName: 'title', //property in marker.options(or feature.properties for vector layer) trough filter elements in layer
propertyLoc: 'loc', //field name for remapping location, using array: ['latname','lonname'] for select double fields(ex. ['lat','lon'] )
//TODO implement sub property filter for propertyName,propertyLoc like this: "prop.subprop.title"
callTip: null, //function that return row tip html node(or html string), receive text tooltip in first param
filterJSON: null, //callback for filtering data to _recordsCache
minLength: 1, //minimal text length for autocomplete
initial: true, //search elements only by initial text
autoType: true, //complete input with first suggested result and select this filled-in text.
delayType: 400, //delay while typing for show tooltip
tooltipLimit: -1, //limit max results to show in tooltip. -1 for no limit.
tipAutoSubmit: true, //auto map panTo when click on tooltip
autoResize: true, //autoresize on input change
autoCollapse: false, //collapse search control after submit(on button or on tips if enabled tipAutoSubmit)
//TODO add option for persist markerLoc after collapse!
autoCollapseTime: 1200, //delay for autoclosing alert and collapse after blur
animateLocation: true, //animate a circle over location found
circleLocation: true, //draw a circle in location found
markerLocation: false, //draw a marker in location found
zoom: null, //zoom after pan to location found, default: map.getZoom()
text: 'Search...', //placeholder value
textCancel: 'Cancel', //title in cancel button
textErr: 'Location not found', //error message
position: 'topleft'
//TODO add option collapsed, like control.layers
},
//FIXME option condition problem {autoCollapse: true, markerLocation: true} not show location
//FIXME option condition problem {autoCollapse: false }
initialize: function(options) {
L.Util.setOptions(this, options || {});
this._inputMinSize = this.options.text ? this.options.text.length : 10;
this._layer = this.options.layer || new L.LayerGroup();
this._filterJSON = this.options.filterJSON || this._defaultFilterJSON;
this._autoTypeTmp = this.options.autoType; //useful for disable autoType temporarily in delete/backspace keydown
this._countertips = 0; //number of tips items
this._recordsCache = {}; //key,value table! that store locations! format: key,latlng
},
onAdd: function (map) {
this._map = map;
this._container = L.DomUtil.create('div', 'leaflet-control-search');
this._input = this._createInput(this.options.text, 'search-input');
this._tooltip = this._createTooltip('search-tooltip');
this._cancel = this._createCancel(this.options.textCancel, 'search-cancel');
this._button = this._createButton(this.options.text, 'search-button');
this._alert = this._createAlert('search-alert');
if(this.options.circleLocation || this.options.markerLocation)
this._markerLoc = new SearchMarker([0,0], {marker: this.options.markerLocation});//see below
this.setLayer( this._layer );
map.on({
// 'layeradd': this._onLayerAddRemove,
// 'layerremove': this._onLayerAddRemove
'resize':this._handleAutoresize()
}, this);
return this._container;
},
onRemove: function(map) {
this._recordsCache = {};
// map.off({
// 'layeradd': this._onLayerAddRemove,
// 'layerremove': this._onLayerAddRemove
// }, this);
},
// _onLayerAddRemove: function(e) {
// //console.info('_onLayerAddRemove');
// //without this, run setLayer also for each Markers!! to optimize!
// if(e.layer instanceof L.LayerGroup)
// if( L.stamp(e.layer) != L.stamp(this._layer) )
// this.setLayer(e.layer);
// },
setLayer: function(layer) { //set search layer at runtime
//this.options.layer = layer; //setting this, run only this._recordsFromLayer()
this._layer = layer;
this._layer.addTo(this._map);
if(this._markerLoc)
this._layer.addLayer(this._markerLoc);
return this;
},
showAlert: function(text) {
text = text || this.options.textErr;
this._alert.style.display = 'block';
this._alert.innerHTML = text;
clearTimeout(this.timerAlert);
var that = this;
this.timerAlert = setTimeout(function() {
that.hideAlert();
},this.options.autoCollapseTime);
return this;
},
hideAlert: function() {
this._alert.style.display = 'none';
return this;
},
cancel: function() {
this._input.value = '';
this._handleKeypress({keyCode:8});//simulate backspace keypress
this._input.size = this._inputMinSize;
this._input.focus();
this._cancel.style.display = 'none';
return this;
},
expand: function() {
this._input.style.display = 'block';
L.DomUtil.addClass(this._container, 'search-exp');
this._input.focus();
this._map.on('dragstart', this.collapse, this);
return this;
},
collapse: function() {
this._hideTooltip();
this.cancel();
this._alert.style.display = 'none';
this._input.style.display = 'none';
this._input.blur();
this._cancel.style.display = 'none';
L.DomUtil.removeClass(this._container, 'search-exp');
//this._markerLoc.hide();//maybe unuseful
this._map.off('dragstart', this.collapse, this);
this.fire('search_collapsed');
return this;
},
collapseDelayed: function() { //collapse after delay, used on_input blur
if (!this.options.autoCollapse) return this;
var that = this;
clearTimeout(this.timerCollapse);
this.timerCollapse = setTimeout(function() {
that.collapse();
}, this.options.autoCollapseTime);
return this;
},
collapseDelayedStop: function() {
clearTimeout(this.timerCollapse);
return this;
},
////start DOM creations
_createAlert: function(className) {
var alert = L.DomUtil.create('div', className, this._container);
alert.style.display = 'none';
L.DomEvent
.on(alert, 'click', L.DomEvent.stop, this)
.on(alert, 'click', this.hideAlert, this);
return alert;
},
_createInput: function (text, className) {
var input = L.DomUtil.create('input', className, this._container);
input.type = 'text';
input.size = this._inputMinSize;
input.value = '';
input.autocomplete = 'off';
input.placeholder = text;
input.style.display = 'none';
L.DomEvent
.disableClickPropagation(input)
.on(input, 'keyup', this._handleKeypress, this)
.on(input, 'keydown', this._handleAutoresize, this)
.on(input, 'blur', this.collapseDelayed, this)
.on(input, 'focus', this.collapseDelayedStop, this);
return input;
},
_createCancel: function (title, className) {
var cancel = L.DomUtil.create('a', className, this._container);
cancel.href = '#';
cancel.title = title;
cancel.style.display = 'none';
cancel.innerHTML = "⊗";//imageless(see css)
L.DomEvent
.on(cancel, 'click', L.DomEvent.stop, this)
.on(cancel, 'click', this.cancel, this);
return cancel;
},
_createButton: function (title, className) {
var button = L.DomUtil.create('a', className, this._container);
button.href = '#';
button.title = title;
L.DomEvent
.on(button, 'click', L.DomEvent.stop, this)
.on(button, 'click', this._handleSubmit, this)
.on(button, 'focus', this.collapseDelayedStop, this)
.on(button, 'blur', this.collapseDelayed, this);
return button;
},
_createTooltip: function(className) {
var tool = L.DomUtil.create('div', className, this._container);
tool.style.display = 'none';
var that = this;
L.DomEvent
.disableClickPropagation(tool)
.on(tool, 'blur', this.collapseDelayed, this)
.on(tool, 'mousewheel', function(e) {
that.collapseDelayedStop();
L.DomEvent.stopPropagation(e);//disable zoom map
}, this)
.on(tool, 'mouseover', function(e) {
that.collapseDelayedStop();
}, this);
return tool;
},
_createTip: function(text, val) {//val is object in recordCache, usually is Latlng
var tip;
if(this.options.callTip)
{
tip = this.options.callTip(text,val); //custom tip node or html string
if(typeof tip === 'string')
{
var tmpNode = L.DomUtil.create('div');
tmpNode.innerHTML = tip;
tip = tmpNode.firstChild;
}
}
else
{
tip = L.DomUtil.create('a', '');
tip.href = '#';
tip.innerHTML = text;
}
L.DomUtil.addClass(tip, 'search-tip');
tip._text = text; //value replaced in this._input and used by _autoType
L.DomEvent
.disableClickPropagation(tip)
.on(tip, 'click', L.DomEvent.stop, this)
.on(tip, 'click', function(e) {
this._input.value = text;
this._handleAutoresize();
this._input.focus();
this._hideTooltip();
if(this.options.tipAutoSubmit)//go to location at once
this._handleSubmit();
}, this);
return tip;
},
//////end DOM creations
_filterRecords: function(text) { //Filter this._recordsCache case insensitive and much more..
var regFilter = new RegExp("^[.]$|[\[\]|()*]",'g'), //remove . * | ( ) ] [
text = text.replace(regFilter,''), //sanitize text
I = this.options.initial ? '^' : '', //search only initial text
regSearch = new RegExp(I + text,'i'),
//TODO add option for case sesitive search, also showLocation
frecords = {};
//TODO use .filter or .map
for(var key in this._recordsCache)
if( regSearch.test(key) )
frecords[key]= this._recordsCache[key];
return frecords;
},
showTooltip: function() {
var filteredRecords, newTip;
this._countertips = 0;
//FIXME problem with jsonp/ajax when remote filter has different behavior of this._filterRecords
if(this.options.layer)
filteredRecords = this._filterRecords( this._input.value );
else
filteredRecords = this._recordsCache;
this._tooltip.innerHTML = '';
this._tooltip.currentSelection = -1; //inizialized for _handleArrowSelect()
for(var key in filteredRecords)//fill tooltip
{
if(++this._countertips == this.options.tooltipLimit) break;
newTip = this._createTip(key, filteredRecords[key] );
this._tooltip.appendChild(newTip);
}
if(this._countertips > 0)
{
this._tooltip.style.display = 'block';
if(this._autoTypeTmp)
this._autoType();
this._autoTypeTmp = this.options.autoType;//reset default value
}
else
this._hideTooltip();
this._tooltip.scrollTop = 0;
return this._countertips;
},
_hideTooltip: function() {
this._tooltip.style.display = 'none';
this._tooltip.innerHTML = '';
return 0;
},
_defaultFilterJSON: function(json) { //default callback for filter data
var jsonret = {},
propName = this.options.propertyName,
propLoc = this.options.propertyLoc;
if( L.Util.isArray(propLoc) )
for(var i in json)
jsonret[ json[i][propName] ]= L.latLng( json[i][ propLoc[0] ], json[i][ propLoc[1] ] );
else
for(var i in json)
jsonret[ json[i][propName] ]= L.latLng( json[i][ propLoc ] );
//TODO verify json[i].hasOwnProperty(propName)
//throw new Error("propertyName '"+propName+"' not found in JSON data");
return jsonret;
},
_recordsFromJsonp: function(text, callAfter) { //extract searched records from remote jsonp service
//TODO remove script node after call run
var that = this;
L.Control.Search.callJsonp = function(data) { //jsonp callback
var fdata = that._filterJSON(data);//_filterJSON defined in inizialize...
callAfter(fdata);
}
var script = L.DomUtil.create('script','search-jsonp', document.getElementsByTagName('body')[0] ),
url = L.Util.template(this.options.url+'&'+this.options.jsonpParam+'=L.Control.Search.callJsonp', {s: text}); //parsing url
//rnd = '&_='+Math.floor(Math.random()*10000);
//TODO add rnd param or randomize callback name! in recordsFromJsonp
script.type = 'text/javascript';
script.src = url;
return this;
//may be return {abort: function() { script.parentNode.removeChild(script); } };
},
_recordsFromAjax: function(text, callAfter) { //Ajax request
if (window.XMLHttpRequest === undefined) {
window.XMLHttpRequest = function() {
try { return new ActiveXObject("Microsoft.XMLHTTP.6.0"); }
catch (e1) {
try { return new ActiveXObject("Microsoft.XMLHTTP.3.0"); }
catch (e2) { throw new Error("XMLHttpRequest is not supported"); }
}
};
}
var request = new XMLHttpRequest(),
url = L.Util.template(this.options.url, {s: text}), //parsing url
//rnd = '&_='+Math.floor(Math.random()*10000);
//TODO add rnd param or randomize callback name! in recordsFromAjax
response = {};
request.open("GET", url);
var that = this;
request.onreadystatechange = function() {
if(request.readyState === 4 && request.status === 200) {
response = window.JSON ? JSON.parse(request.responseText) : eval("("+ request.responseText + ")");
var fdata = that._filterJSON(response);//_filterJSON defined in inizialize...
callAfter(fdata);
}
};
request.send();
return this;
},
_recordsFromLayer: function() { //return table: key,value from layer
var retRecords = {},
propName = this.options.propertyName,
loc;
this._layer.eachLayer(function(layer) {
if(layer instanceof SearchMarker) return;
if(layer instanceof L.Marker)
{
if(layer.options.hasOwnProperty(propName))
{
loc = layer.getLatLng();
loc.layer = layer;
retRecords[ layer.options[propName] ] = loc;
}else if(layer.feature.properties.hasOwnProperty(propName)){
loc = layer.getLatLng();
loc.layer = layer;
retRecords[ layer.feature.properties[propName] ] = loc;
}else{
console.log("propertyName '"+propName+"' not found in marker", layer);
}
}
else if(layer.hasOwnProperty('feature'))//GeoJSON layer
{
if(layer.feature.properties.hasOwnProperty(propName))
{
loc = layer.getBounds().getCenter();
loc.layer = layer;
retRecords[ layer.feature.properties[propName] ] = loc;
}
else
console.log("propertyName '"+propName+"' not found in feature", layer);
}
},this);
return retRecords;
},
_autoType: function() {
//TODO implements autype without selection(useful for mobile device)
var start = this._input.value.length,
firstRecord = this._tooltip.firstChild._text,
end = firstRecord.length;
if (firstRecord.indexOf(this._input.value) == 0) { // If prefix match
this._input.value = firstRecord;
this._handleAutoresize();
if (this._input.createTextRange) {
var selRange = this._input.createTextRange();
selRange.collapse(true);
selRange.moveStart('character', start);
selRange.moveEnd('character', end);
selRange.select();
}
else if(this._input.setSelectionRange) {
this._input.setSelectionRange(start, end);
}
else if(this._input.selectionStart) {
this._input.selectionStart = start;
this._input.selectionEnd = end;
}
}
},
_hideAutoType: function() { // deselect text:
var sel;
if ((sel = this._input.selection) && sel.empty) {
sel.empty();
}
else if (this._input.createTextRange) {
sel = this._input.createTextRange();
sel.collapse(true);
var end = this._input.value.length;
sel.moveStart('character', end);
sel.moveEnd('character', end);
sel.select();
}
else {
if (this._input.getSelection) {
this._input.getSelection().removeAllRanges();
}
this._input.selectionStart = this._input.selectionEnd;
}
},
_handleKeypress: function (e) { //run _input keyup event
switch(e.keyCode)
{
case 27: //Esc
this.collapse();
break;
case 13: //Enter
if(this._countertips == 1)
this._handleArrowSelect(1);
this._handleSubmit(); //do search
break;
case 38://Up
this._handleArrowSelect(-1);
break;
case 40://Down
this._handleArrowSelect(1);
break;
case 37://Left
case 39://Right
case 16://Shift
case 17://Ctrl
//case 32://Space
break;
case 8://backspace
case 46://delete
this._autoTypeTmp = false;//disable temporarily autoType
default://All keys
if(this._input.value.length)
this._cancel.style.display = 'block';
else
this._cancel.style.display = 'none';
if(this._input.value.length >= this.options.minLength)
{
var that = this;
clearTimeout(this.timerKeypress); //cancel last search request while type in
this.timerKeypress = setTimeout(function() { //delay before request, for limit jsonp/ajax request
that._fillRecordsCache();
}, this.options.delayType);
}
else
this._hideTooltip();
}
},
_fillRecordsCache: function() {
//TODO important optimization!!! always append data in this._recordsCache
// now _recordsCache content is emptied and replaced with new data founded
// always appending data on _recordsCache give the possibility of caching ajax, jsonp and layersearch!
//
//TODO here insert function that search inputText FIRST in _recordsCache keys and if not find results..
// run one of callbacks search(callData,jsonpUrl or options.layer) and run this.showTooltip
//
//TODO change structure of _recordsCache
// like this: _recordsCache = {"text-key1": {loc:[lat,lng], ..other attributes.. }, {"text-key2": {loc:[lat,lng]}...}, ...}
// in this mode every record can have a free structure of attributes, only 'loc' is required
var inputText = this._input.value;
L.DomUtil.addClass(this._container, 'search-load');
if(this.options.callData) //CUSTOM SEARCH CALLBACK(USUALLY FOR AJAX SEARCHING)
{
var that = this;
this.options.callData(inputText, function(jsonraw) {
that._recordsCache = that._filterJSON(jsonraw);
that.showTooltip();
L.DomUtil.removeClass(that._container, 'search-load');
});
}
else if(this.options.url) //JSONP/AJAX REQUEST
{
if(this.options.jsonpParam)
{
var that = this;
this._recordsFromJsonp(inputText, function(data) {// is async request then it need callback
that._recordsCache = data;
that.showTooltip();
L.DomUtil.removeClass(that._container, 'search-load');
});
}
else
{
var that = this;
this._recordsFromAjax(inputText, function(data) {// is async request then it need callback
that._recordsCache = data;
that.showTooltip();
L.DomUtil.removeClass(that._container, 'search-load');
});
}
}
else if(this.options.layer) //SEARCH ELEMENTS IN PRELOADED LAYER
{
this._recordsCache = this._recordsFromLayer(); //fill table key,value from markers into layer
this.showTooltip();
L.DomUtil.removeClass(this._container, 'search-load');
}
},
_handleAutoresize: function() { //autoresize this._input
//TODO refact _handleAutoresize now is not accurate
if (this._input.style.maxWidth != this._map._container.offsetWidth) //If maxWidth isn't the same as when first set, reset to current Map width
this._input.style.maxWidth = L.DomUtil.getStyle(this._map._container, 'width');
if(this.options.autoResize && (this._container.offsetWidth + 45 < this._map._container.offsetWidth))
this._input.size = this._input.value.length= (searchTips.length - 1))) {// If at end of list.
L.DomUtil.addClass(searchTips[this._tooltip.currentSelection], 'search-tip-select');
}
else if ((velocity == -1 ) && (this._tooltip.currentSelection <= 0)) { // Going back up to the search box.
this._tooltip.currentSelection = -1;
}
else if (this._tooltip.style.display != 'none') { // regular up/down
this._tooltip.currentSelection += velocity;
L.DomUtil.addClass(searchTips[this._tooltip.currentSelection], 'search-tip-select');
this._input.value = searchTips[this._tooltip.currentSelection]._text;
// scroll:
var tipOffsetTop = searchTips[this._tooltip.currentSelection].offsetTop;
if (tipOffsetTop + searchTips[this._tooltip.currentSelection].clientHeight >= this._tooltip.scrollTop + this._tooltip.clientHeight) {
this._tooltip.scrollTop = tipOffsetTop - this._tooltip.clientHeight + searchTips[this._tooltip.currentSelection].clientHeight;
}
else if (tipOffsetTop <= this._tooltip.scrollTop) {
this._tooltip.scrollTop = tipOffsetTop;
}
}
},
_handleSubmit: function() { //button and tooltip click and enter submit
this._hideAutoType();
this.hideAlert();
this._hideTooltip();
if(this._input.style.display == 'none') //on first click show _input only
this.expand();
else
{
if(this._input.value == '') //hide _input only
this.collapse();
else
{
var loc = this._getLocation(this._input.value);
if(loc===false)
this.showAlert();
else
{
this.showLocation(loc, this._input.value);
this.fire('search_locationfound', {
latlng: loc,
text: this._input.value,
layer: loc.layer ? loc.layer : null
});
}
//this.collapse();
//FIXME if collapse in _handleSubmit hide _markerLoc!
}
}
},
_getLocation: function(key) { //extract latlng from _recordsCache
if( this._recordsCache.hasOwnProperty(key) )
return this._recordsCache[key];//then after use .loc attribute
else
return false;
},
showLocation: function(latlng, title) { //set location on map from _recordsCache
if(this.options.zoom)
this._map.setView(latlng, this.options.zoom);
else
this._map.panTo(latlng);
if(this._markerLoc)
{
this._markerLoc.setLatLng(latlng); //show circle/marker in location found
this._markerLoc.setTitle(title);
this._markerLoc.show();
if(this.options.animateLocation)
this._markerLoc.animate();
//TODO showLocation: start animation after setView or panTo, maybe with map.on('moveend')...
}
//FIXME autoCollapse option hide this._markerLoc before that visualized!!
if(this.options.autoCollapse)
this.collapse();
return this;
}
});
var SearchMarker = L.Marker.extend({
includes: L.Mixin.Events,
options: {
radius: 10,
weight: 3,
color: '#e03',
stroke: true,
fill: false,
title: '',
//TODO add custom icon!
marker: false //show icon optional, show only circleLoc
},
initialize: function (latlng, options) {
L.setOptions(this, options);
L.Marker.prototype.initialize.call(this, latlng, options);
this._circleLoc = new L.CircleMarker(latlng, this.options);
//TODO add inner circle
},
onAdd: function (map) {
L.Marker.prototype.onAdd.call(this, map);
map.addLayer(this._circleLoc);
this.hide();
},
onRemove: function (map) {
L.Marker.prototype.onRemove.call(this, map);
map.removeLayer(this._circleLoc);
},
setLatLng: function (latlng) {
L.Marker.prototype.setLatLng.call(this, latlng);
this._circleLoc.setLatLng(latlng);
return this;
},
setTitle: function(title) {
title = title || '';
this.options.title = title;
if(this._icon)
this._icon.title = title;
return this;
},
show: function() {
if(this.options.marker)
{
if(this._icon)
this._icon.style.display = 'block';
if(this._shadow)
this._shadow.style.display = 'block';
//this._bringToFront();
}
if(this._circleLoc)
{
this._circleLoc.setStyle({fill: this.options.fill, stroke: this.options.stroke});
//this._circleLoc.bringToFront();
}
return this;
},
hide: function() {
if(this._icon)
this._icon.style.display = 'none';
if(this._shadow)
this._shadow.style.display = 'none';
if(this._circleLoc)
this._circleLoc.setStyle({fill: false, stroke: false});
return this;
},
animate: function() {
//TODO refact animate() more smooth! like this: http://goo.gl/DDlRs
var circle = this._circleLoc,
tInt = 200, //time interval
ss = 10, //frames
mr = parseInt(circle._radius/ss),
oldrad = this.options.radius,
newrad = circle._radius * 2.5,
acc = 0;
circle._timerAnimLoc = setInterval(function() {
acc += 0.5;
mr += acc; //adding acceleration
newrad -= mr;
circle.setRadius(newrad);
if(newrad