backbone.facetr
Version:
A library to perform faceted search on Backbone collections
644 lines (543 loc) • 25 kB
JavaScript
var Facet = function(facetName, modelsMap, vent, extOperator) {
// give facet instances ability to handle Backbone Events
_.extend(this, Backbone.Events);
// init local variables with default values
var _self = this,
_name = facetName, // corresponds to the model attribute name or 'Property.Property1.PropertyN' for deeper relations
_label = facetName, // a human readable label
_sortBy = 'value', // determines if facet values are sorted by 'value' or 'count'
_sortDirection = 'asc', // determines facet values sort direction
_values = [], // facet values computed from collection models (Ex.: { value: 'Title', count: 2 })
_activeValues = [], // currently selected facet values
_activeModels = [], // active models according to currently selected values
_valModelMap = {}, // a map of values to model, used for fast filtering. it exploits the unique 'cid' given to each Backbone.Model
_extOperator = extOperator, // external facet operator, used by FacetrCollection when filtering the Backbone.Collection
_operator = 'or', // default internal operator
_selected = false, // a flag which is set to true if any of the facet values is selected,
_customData = {}, // a map of custom data which can be added to the facet
_isHierarchical = false,
_groups = [],
_groupedValues = [],
_hierarchyNodesDescendants = {},
_hierarchyNodesAncestors = {},
// creates a facetValue with count 1 or increases the count by 1 of an existing faceValue in values
_begetFacetValue = function(facetValues, value, cid) {
var val = (value !== undefined && value !== null) ? value : 'undefined', isObj = false;
// TODO - find better solution, this is just a quick fix
// Problem: case of property consisting of Array of Objects was overlooked
// in original implementation of Dot Notation
if(typeof val != null && typeof val === 'object') {
var attr = _name.split('.')[1];
val = attr && (val instanceof Backbone.Model && val.get(attr) || val[attr]) || 'undefined';
isObj = true;
}
var facetValue = _.find(facetValues, function(v) {
return v.value === val;
});
if(facetValue) {
facetValue.count = facetValue.count + 1;
facetValue.activeCount = facetValue.activeCount + 1;
} else {
// use sort index to get index where value should be put in order to keep values sorted
var sortedIndex = _.sortedIndex(_.pluck(_values, 'value'), val);
// note that at init values are sorted by the default sort property and direction, i.e. 'value' and 'asc'
_values.splice(sortedIndex, 0, {
value : val,
count : 1,
activeCount : 1,
active : false
});
// create an entry in the value to model map for the given value
_valModelMap[val] = [];
}
if(isObj) {
_valModelMap[val].push(cid);
}
return val;
},
_parseModel = function(model) {
var value = _getValue(model, _name), val;
if(value instanceof Array) {
if(value.length === 0){
val = _begetFacetValue(_values, 'undefined', model.cid);
_valModelMap[val].push(model.cid);
} else {
_.each(value, function(v) {
val = _begetFacetValue(_values, v, model.cid);
// push the model.cid in the current value entry of the value to model map
if(_valModelMap[val]) {
_valModelMap[val].push(model.cid);
}
});
}
} else {
if(value != null && typeof value === 'object') {
_self.remove();
throw new Error('Model property can only be a value (string,number,boolean) or Array of values, not an object');
}
val = _begetFacetValue(_values, value);
_valModelMap[val].push(model.cid);
}
},
// reads model.get(facetName) from the models in the collection and populates the array of values
_computeValues = function(modelsMap) {
_(modelsMap).each(function(model) {
_parseModel(model);
});
},
_sortFn = function(v1,v2) {
if(_sortBy === 'value') {
// note that facet values are always unique, so v1.value === v2.value is never true
return (_sortDirection === 'asc') ? ((v1.value > v2.value)-(v1.value < v2.value)) : ((v1.value < v2.value)-(v1.value > v2.value));
} else if(_sortBy === 'activeCount') {
if(_sortDirection === 'asc') {
if(v1.activeCount < v2.activeCount) {
return -1;
} else if(v1.activeCount > v2.activeCount) {
return 1;
} else {
// if both facet values have same activeCount, sort by value
return ((v1.value > v2.value)-(v1.value < v2.value));
}
} else {
if(v1.activeCount < v2.activeCount) {
return 1;
} else if(v1.activeCount > v2.activeCount) {
return -1;
} else {
return ((v1.value < v2.value)-(v1.value > v2.value));
}
}
} else {
if(_sortDirection === 'asc') {
if(v1.count < v2.count) {
return -1;
} else if(v1.count > v2.count) {
return 1;
} else {
// if both facet values have same count, sort by value
return ((v1.value > v2.value)-(v1.value < v2.value));
}
} else {
if(v1.count < v2.count) {
return 1;
} else if(v1.count > v2.count) {
return -1;
} else {
return ((v1.value < v2.value)-(v1.value > v2.value));
}
}
}
},
// sort method sorts the facet values according to the given sortBy and sortDirection
_sort = function() {
_values.sort(_sortFn);
if(_isHierarchical){
_groupedValues.sort(_sortFn);
}
},
// checks if any of the facet values is currently selected by testing if _activeValues is empty
_isSelected = function() {
return _activeValues.length !== 0;
},
// compute active facet values count
_computeActiveValuesCount = function(filteredModels) {
for(var i = 0, len = _values.length; i < len; i += 1) {
// if(filteredModels.length !== 0) {
// compute intersection of value models and filtered models and store the length
var intersectLen = _.intersection(_valModelMap[_values[i].value], filteredModels).length;
// if any filtered model is in the value models
if(intersectLen !== 0) {
// value active count is the length of the intersection
_values[i].activeCount = intersectLen;
} else {
// if no values are selected for the facet and the value is not in any of the filtered models
// then the value active count is equal 0
_values[i].activeCount = 0;
}
}
},
// invoked whenever a Backbone collection is reset
_resetFacet = function(modelsMap) {
// empty current select values and copy them in a new array
var activeValCopy = _activeValues.splice(0, _activeValues.length);
// empty _values and_activeModels arrays
_values.splice(0, _values.length);
_activeModels.splice(0, _activeModels.length);
// reset _valModelMap
_valModelMap = {};
// recompute values
_computeValues(modelsMap);
// readd each value which was selected before the reset to the facet
for(var i = 0, len = activeValCopy.length; i < len; i += 1) {
_self.value(activeValCopy[i]);
}
},
// invoked whenever a new Model instance is added to the Backbone collection
_addModel = function(model) {
// parse the new model properties to update _values and other local data structures
_parseModel(model);
// reapply active values to account the new model
for(var i = 0, len = _activeValues.length; i < len; i += 1) {
_self.value(_activeValues[i]);
}
},
// invoked whenever a model is removed to the Backbone collection
_removeModel = function(model) {
var cid = model.cid,
index,
value,
decrementValue = function(value) {
index = _values.length-1;
while(index !== -1) {
var v = _values[index];
if(v.value === value) {
// update count and activeCount
v.count -= 1;
v.activeCount -= 1;
// remove value if count is 0 meaning no models have this value anymore
if(v.count === 0) {
_values.splice(index,1);
// remove the value from active values
var j = _.indexOf(_activeValues,v.value);
if(j !== -1) {
_activeValues.splice(j,1);
// check if facet is still selected after removing the value
_selected = _isSelected();
}
}
// stop the loop as the value was already found
break;
}
index -= 1;
}
};
// remove model cid from val to model map
for(var val in _valModelMap) {
if(_valModelMap.hasOwnProperty(val)) {
index = _.indexOf(_valModelMap[val], cid);
if(index !== -1) {
_valModelMap[val].splice(index, 1);
}
}
}
value = _getValue(model, _name);
// decrement count and active count for the value
if(value) {
if(value instanceof Array) {
for(var i = 0, len = value.length; i < len; i++) {
decrementValue(value[i]);
}
} else {
decrementValue(value);
}
}
// remove model from active models
index = _.indexOf(_activeModels, cid);
if(index !== -1) {
_activeModels.splice(index, 1);
}
},
// invoked whenever a model is changed
_changeModel = function(model) {
var prev, prevVal;
// check if the value changed was a value of this facet, if not nothing needs to be done
if(_getValue(new Backbone.Model(model.changedAttributes()), _name)) {
// create a clone of model previous state
prev = new Backbone.Model(model.previousAttributes());
prevVal = _getValue(prev, _name);
prev.cid = model.cid;
// remove the old state of the model
_removeModel(prev);
// add the new state of the model
_addModel(model);
}
},
// returns an array with all the values of descendant nodes of the hierarchy node argument
_computeHierarchyNodeDescendants = function(hierarchyNode){
var values, getDescendants;
if(_hierarchyNodesDescendants[hierarchyNode.value] == null){
_hierarchyNodesDescendants[hierarchyNode.value] = [];
}
values = [];
getDescendants = function(node){
if(node.value !== hierarchyNode.value){
values.push(node.value);
}
if(node.groups){
_.each(node.groups, getDescendants);
}
};
getDescendants(hierarchyNode);
_hierarchyNodesDescendants[hierarchyNode.value] = _.union(_hierarchyNodesDescendants[hierarchyNode.value], values);
},
_computeHierarchyNodeAncestors = function(hierarchyNode){
var populate = function(values, parent, node){
if(_hierarchyNodesAncestors[node.value] == null){
_hierarchyNodesAncestors[node.value] = [];
}
if(parent){
values.push(parent.value);
_hierarchyNodesAncestors[node.value] = _.union(_hierarchyNodesAncestors[node.value], values);
}
if(node.groups){
_.each(node.groups, function(n){
return populate(values, node, n);
});
}
};
populate([], undefined, hierarchyNode);
},
// computes the values for each node in the groups property of the hierarchy node argument
_computeHierarchyGroup = function(hierarchyNode){
var i, len, root, groups, val;
groups = hierarchyNode.groups;
root = {
value: hierarchyNode.value,
label: hierarchyNode.label,
active: false,
activeCount: 0,
count: 0
};
val = _.find(_values, function(v){
return v.value === root.value;
});
if(val){
_.extend(root, val);
}
// check if hierarchy node as a groups property
if(groups && Object.prototype.toString.call(groups) === "[object Array]" && (len = groups.length) > 0){
root.groups = [];
for(i = 0; i < len; i += 1){
root.groups.push(_computeHierarchyGroup(groups[i]));
}
root.activeCount = _.reduce(root.groups, function(memo, num){
return memo + num.activeCount;
}, root.activeCount);
root.count = _.reduce(root.groups, function(memo, num){
return memo + num.count;
}, root.count);
}
_computeHierarchyNodeDescendants(root);
return root;
},
// compute all hierarchy groups
_computeHierarchyGroups = function() {
var i, len;
_groupedValues.length = 0;
for(i = 0, len = _groups.length; i < len; i += 1){
_groupedValues.push(_computeHierarchyGroup(_groups[i]));
_computeHierarchyNodeAncestors(_groups[i]);
}
_groupedValues.sort(_sortFn);
},
// a FacetExp object is returned by the Facet.value method to enable logical filters chaining
FacetExp = function() {
// and method
this.and = function(facetValue, silent) {
_operator = 'and';
_self.value(facetValue, silent);
return this;
};
// or method
this.or = function(facetValue, silent) {
_operator = 'or';
_self.value(facetValue, silent);
return this;
};
};
// setters return always this Facet instance for method chaining
this.label = function(label) { _label = label; return this; }; // sets the label to the given string
this.asc = function() { _sortDirection = 'asc'; _sort(); return this; }; // sets sort direction to asc
this.desc = function() { _sortDirection = 'desc'; _sort(); return this; }; // sets sort direction to desc
this.sortByValue = function() { _sortBy = 'value'; _sort(); return this; }; // sets sortBy facet name
this.sortByCount = function() { _sortBy = 'count'; _sort(); return this; }; // sets sortBy facet value count
this.sortByActiveCount = function() { _sortBy = 'activeCount'; _sort(); return this; };
// returns a JSON object containing this Facet instance info and values
this.toJSON = function() {
var obj;
obj = {
data : {
name : _name,
hierarchical: _isHierarchical,
label : _label,
extOperator : _extOperator,
intOperator : _operator,
sort : {
by : _sortBy,
direction : _sortDirection
},
selected : _selected,
customData : _customData
},
values : _values
};
if(_isHierarchical){
obj.groupedValues = _groupedValues;
}
return obj;
};
// removes this facet from the FacetCollection, by delegating removal operations to the FacetCollection instance
this.remove = function(silent) {
// detach event listeners from collection vent object
vent.off('resetCollection', _computeActiveValuesCount);
vent.off('resetOrigCollection', _resetFacet);
vent.off('addModel', _addModel);
vent.off('removeModel', _removeModel);
vent.off('changeModel', _changeModel);
// trigger an event to notify the FacetCollection instance of the change
// which will then remove the facet from the facets object
vent.trigger('removeFacet', _name, silent);
};
// adds the given value
// returns a FacetExp object, which can be used to chain facet value selectors with
// logical operators
this.value = function(facetValue, operator, silent) {
var i, len, setsFn, valueIndex, value, valHierarchy;
if(operator) {
_operator = operator;
}
// get the index of the value in the _values array
valueIndex = _.chain(_values).pluck('value').indexOf(facetValue).value();
// check if value exists
if(valueIndex !== -1) {
value = _values[valueIndex];
// set the facet value to active only if it is not already so
if(!value.active) {
value.active = true;
_activeValues.push(value.value);
}
// depending on the operator
setsFn = (_operator === 'or' || _activeModels.length === 0) ? _.union : _.intersection;
// compute active models as the union/intersection of existing active models and facet value models
_activeModels = setsFn(_activeModels, _valModelMap[facetValue]);
// in case of hierarchical facet
if(_isHierarchical && _hierarchyNodesDescendants[value.value] != null && (valHierarchy = _hierarchyNodesDescendants[value.value]).length > 0){
_activeValues.concat(valHierarchy);
// filter collection also by all values in descendant groups of the current one
for(i = 0, len = valHierarchy.length; i < len; i += 1){
_activeModels = setsFn(_activeModels, _valModelMap[valHierarchy[i]]);
}
}
} else {
// no value found, add one with no models associated only if not already present
if(_activeValues.indexOf(facetValue) === -1){
_activeValues.push(facetValue);
if(_operator === 'and'){
_activeModels = [];
}
}
}
// update is local _selected value
_selected = _isSelected();
// trigger a value event to notify the FacetCollection about the change
vent.trigger('value', _name, facetValue, _activeModels, silent);
if(!silent){
this.trigger('value', facetValue);
}
// return a FacetExp object to allow Facetr expression chain
return new FacetExp(this, _operator);
};
// removes the given value
this.removeValue = function(facetValue, silent) {
var valueIndex = _.chain(_values).pluck('value').indexOf(facetValue).value(),
value, modelsToAdd, modelsToRemove;
// check if value exists
if(valueIndex !== -1) {
value = _values[valueIndex];
value.active = false;
}
// remove value from active values array
_activeValues.splice(_.indexOf(_activeValues, value && value.value || facetValue), 1);
// compute models to remove from the active models due to facet value deselection
// need to filter out models which are included in active models due to another
// facet value being selected (thing that can happen on model properties which are of type Array)
modelsToRemove = _.filter(_valModelMap[facetValue], function(cid) {
for(var value in _valModelMap) {
if(_valModelMap.hasOwnProperty(value)) {
// check if any other active value has a model which is also in the facet value being removed
// if yes, the model cannot be removed from the result set
if(_.indexOf(_activeValues, value) !== -1 && _.indexOf(_valModelMap[value], cid) !== -1) {
return false;
}
}
}
// if the model is only in the facet value being removed, then it can be removed
return true;
});
// remove inactive models from the active models list
_activeModels = _.difference(_activeModels, modelsToRemove);
if(_operator === 'and') {
modelsToAdd = [];
for(var i = 0, len = _activeValues.length; i < len; i += 1) {
if(modelsToAdd.length === 0) {
modelsToAdd = _.union(modelsToAdd, _valModelMap[_activeValues[i]]);
} else {
modelsToAdd = _.intersection(modelsToAdd, _valModelMap[_activeValues[i]]);
}
}
_activeModels = _.union(_activeModels, modelsToAdd);
}
// if facet is hierarchical
if(_isHierarchical && _hierarchyNodesAncestors[facetValue] != null){
// check if a parent of the value being removed is selected
var ancestors = _.intersection(_hierarchyNodesAncestors[facetValue], _activeValues);
if(ancestors.length > 0){
// if yes, need to readd value models
_activeModels = _.union(_activeModels, _valModelMap[facetValue]);
}
}
// update local _selected value
_selected = _isSelected();
// notify the FacetCollection to update this facet values
vent.trigger('removeValue', _name, facetValue, _activeModels, silent);
if(!silent){
this.trigger('removeValue', facetValue);
}
return this;
};
// removes all selected values
this.clear = function(silent) {
while(_activeValues.length > 0) {
this.removeValue(_activeValues[0], true);
}
if(!silent){
this.trigger('clear');
}
return this;
};
// attaches custom data, which can be retrieved by key
this.customData = function(key, value) {
if(value !== undefined) {
_customData[key] = value;
return this;
}
return _customData[key];
};
// returns true if any value is selected
this.isSelected = function(){
return _selected;
};
// creates hierarchical representation of the values based on groups settins
this.hierarchy = function(settings){
if(Object.prototype.toString.call(settings) !== '[object Array]') {
throw new Error('Facet.hierarchy: wrong settings object. Check the documentation for the right format');
}
_groups = settings;
_isHierarchical = true;
_computeHierarchyGroups();
vent.on('resetCollection addModel removeModel changeModel', _computeHierarchyGroups);
this.trigger('hierarchy', _groups);
return this;
};
// compute values once the facet is added to the FacetCollection
_computeValues(modelsMap);
// compute facet values count on collection reset
vent.on('resetCollection', _computeActiveValuesCount);
vent.on('resetCollection', _sort);
// bind actions on Backbone Collection events, shived by the FacetCollection instance
vent.on('resetOrigCollection', _resetFacet);
vent.on('addModel', _addModel);
vent.on('removeModel', _removeModel);
vent.on('changeModel', _changeModel);
};