pourover
Version:
A library for simple, fast filtering and sorting of large collections in the browser
1,510 lines (1,397 loc) • 77.3 kB
JavaScript
var PourOver = (function() {
var deprecationWarn = function(name, nextName) {
console.warn("Deprecation warning: " + name + " will be renamed to " + nextName + " in the next major release.");
};
var ctor = function() {};
var create = _.create || function(prototype) {
ctor.prototype = prototype;
var result = new ctor();
ctor.prototype = null;
return result;
};
PourOver = {
// Utility functions. Skip down to "Collections" for the real meat of PourOver.
//
// # The basic sorted set operations
//
union_sorted: function(a, b) {
deprecationWarn("union_sorted", "unionSort");
return this.unionSort(a,b);
},
unionSort: function(a, b) {
// Make more efficient by just copying at Infinity
var lowa = 0,
lowb = 0,
higha = a.length,
highb = b.length,
result = [],
la, lb;
while (higha > lowa || highb > lowb) {
la = a[lowa];
lb = b[lowb];
if (_.isUndefined(la)) la = Infinity;
if (_.isUndefined(lb)) lb = Infinity;
if (lowa == higha) {
return result.concat(b.slice(lowb, highb));
}
if (lowb == highb) {
return result.concat(a.slice(lowa, higha));
}
if (la == lb) {
result.push(la);
lowa++;
lowb++;
} else if (la < lb) {
result.push(la);
lowa++;
} else {
result.push(lb);
lowb++;
}
}
return result;
},
intersect_sorted: function(a, b) {
deprecationWarn("intersect_sorted", "intersectSort");
return this.intersectSort(a, b);
},
intersectSort: function(a, b) {
var lowa = 0,
lowb = 0,
higha = a.length,
highb = b.length,
result = [],
la, lb;
while (higha > lowa && highb > lowb) {
la = a[lowa];
lb = b[lowb];
if (la == lb) {
result.push(la);
lowa++;
lowb++;
} else if (la < lb) {
lowa++;
} else {
lowb++;
}
}
return result;
},
subtract_sorted: function(a, b) {
deprecationWarn("subtract_sorted", "subtractSort");
return this.subtractSort(a, b);
},
subtractSort: function(a, b) {
var lowa = 0,
lowb = 0,
higha = a.length,
highb = b.length,
result = [],
la, lb;
while (higha > lowa || highb > lowb) {
la = a[lowa];
lb = b[lowb];
if (higha == lowa) {
return result;
}
if (highb == lowb) {
return result.concat(a.slice(lowa, higha));
}
if (la == lb) {
lowa++;
lowb++;
} else if (la < lb) {
result.push(la);
lowa++;
} else {
lowb++;
}
}
return result;
},
insert_sorted: function(a, b) {
deprecationWarn("insert_sorted", "insertSort");
return this.insertSort(a, b);
},
insertSort: function(set, element) {
var length = set.length,
i = 0,
last_elem = set[length - 1];
if (element > last_elem) {
set.push(element);
return set;
}
while (i < length) {
if (element < set[i]) {
return set.slice(0, i).concat([element]).concat(set.slice(i, length));
} else {
i++;
}
}
set.push(element);
return set;
},
//
// # Sort support
//
// Sort the set according to some function and then store an array of the translations
// of the indicies. So if the first item went to index 2 after being sorted, put 2 in
// the first spot of the permutation array.
build_permutation_array: function(a, b) {
deprecationWarn("build_permutation_array", "buildPermutationArray");
return this.buildPermutationArray(a, b);
},
buildPermutationArray: function(set, sort) {
var sorted_set = _.clone(set),
perm = [];
if (typeof(sort) === "function") {
sorted_set.sort(sort);
} else {
sorted_set.sort(function(a, b) {
return sort.fn.call(sort, a, b);
});
}
_.each(sorted_set, function(m, i) {
perm[m.cid] = i;
});
return perm;
},
// Use a permutation array to resort a subset of a collection.
permute_from_array: function(a, b) {
deprecationWarn("permute_from_array", "permuteFromArray");
return this.permuteFromArray(a, b);
},
permuteFromArray: function(collection, perm) {
var output = [];
if (typeof(collection[0]) === "number") {
_.each(collection, function(i) {
output[perm[i]] = i;
});
} else {
_.each(collection, function(i) {
output[perm[i.cid]] = i;
});
}
return _.without(output, undefined);
},
// Remove an element from a sorted set.
remove_sorted: function(a, b) {
deprecationWarn("remove_sorted", "removeSort");
return this.removeSort(a, b);
},
removeSort: function(set, element) {
var length = set.length,
i = 0;
while (i < length) {
if (element == set[i]) {
return set.slice(0, i).concat(set.slice(i + 1, length));
} else {
i++;
}
}
return set;
},
bisect_by: function(f) {
deprecationWarn("bisect_by", "bisectBy");
return this.bisectBy(f);
},
bisectBy: function(f) {
// Thanks to crossfilter (https://github.com/square/crossfilter) for this implementation.
function bisectLeft(a, x, lo, hi) {
while (lo < hi) {
var mid = lo + hi >>> 1;
if (f(a[mid]) < x) lo = mid + 1;
else hi = mid;
}
return lo;
}
function bisectRight(a, x, lo, hi) {
while (lo < hi) {
var mid = lo + hi >>> 1;
if (x < f(a[mid])) hi = mid;
else lo = mid + 1;
}
return lo;
}
bisectRight.right = bisectRight;
bisectRight.left = bisectLeft;
return bisectRight;
},
// # Pre-defined cache methods
// Caching is really the raison d'etre of Pourover. Every filter has two cache methods: one for rebuilding the whole filter from scratch
// and one for adding new items. As Pourover grows it will gain more pre-defined cache methods that correlate with common UI and data patterns.
cacheMethods: {
// ### Default: the dumb caches.
// Just goes through each possible value for the filter and tests every item in the collection against it. As expensive as
// possibile, but simple.
defaultCache: function(items) {
var that = this;
_.each(that.possibilities, function(p) {
var matching_items = _.filter(items, function(i) {
return that.fn(p, i);
}),
matching_cids = _.pluck(matching_items, 'cid');
p.matching_cids = matching_cids;
});
},
defaultAddCache: function(items) {
var that = this;
_.each(that.possibilities, function(p) {
var matching_items = _.filter(items, function(i) {
return that.fn(p, i);
}),
matching_cids = _.pluck(matching_items, 'cid');
p.matching_cids = PourOver.unionSort(p.matching_cids, matching_cids);
});
},
// ### Exact: the fastest caches.
// For filters that evaluate by strict equality (this property === this value). The name of the filter must
// match the name of the property for exact cache to work.
exactCache: function(items) {
var that = this,
attr = this.attr || this.name;
_.each(items, function(i) {
var p = that.possibilities[i[attr]];
if (p) {
p.matching_cids = PourOver.insertSort(p.matching_cids, i.cid);
}
});
},
exactAddCache: function(items) {
PourOver.cacheMethods.exactCache.call(this, items);
},
inclusionCache: function(items) {
var that = this,
attr = this.attr || this.name;
_.each(items, function(i) {
_.each(i[attr], function(v) {
var p = that.possibilities[v];
if (p) {
p.matching_cids = PourOver.insertSort(p.matching_cids, i.cid);
}
});
});
},
inclusionAddCache: function(items) {
PourOver.cacheMethods.inclusionCache.call(this, items);
}
}
};
// Copied from Backbone
var array = [];
var push = array.push;
var slice = array.slice;
var splice = array.splice;
var Events = PourOver.Events = {
// Bind an event to a `callback` function. Passing `"all"` will bind
// the callback to all events fired.
on: function(name, callback, context) {
if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this;
this._events || (this._events = {});
var events = this._events[name] || (this._events[name] = []);
events.push({
callback: callback,
context: context,
ctx: context || this
});
return this;
},
// Bind an event to only be triggered a single time. After the first time
// the callback is invoked, it will be removed.
once: function(name, callback, context) {
if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this;
var self = this;
var once = _.once(function() {
self.off(name, once);
callback.apply(this, arguments);
});
once._callback = callback;
return this.on(name, once, context);
},
// Remove one or many callbacks. If `context` is null, removes all
// callbacks with that function. If `callback` is null, removes all
// callbacks for the event. If `name` is null, removes all bound
// callbacks for all events.
off: function(name, callback, context) {
var retain, ev, events, names, i, l, j, k;
if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this;
if (!name && !callback && !context) {
this._events = void 0;
return this;
}
names = name ? [name] : _.keys(this._events);
for (i = 0, l = names.length; i < l; i++) {
name = names[i];
if (events == this._events[name]) {
this._events[name] = retain = [];
if (callback || context) {
for (j = 0, k = events.length; j < k; j++) {
ev = events[j];
if ((callback && callback !== ev.callback && callback !== ev.callback._callback) ||
(context && context !== ev.context)) {
retain.push(ev);
}
}
}
if (!retain.length) delete this._events[name];
}
}
return this;
},
// Trigger one or many events, firing all bound callbacks. Callbacks are
// passed the same arguments as `trigger` is, apart from the event name
// (unless you're listening on `"all"`, which will cause your callback to
// receive the true name of the event as the first argument).
trigger: function(name) {
if (!this._events) return this;
var args = slice.call(arguments, 1);
if (!eventsApi(this, 'trigger', name, args)) return this;
var events = this._events[name];
var allEvents = this._events.all;
if (events) triggerEvents(events, args);
if (allEvents) triggerEvents(allEvents, arguments);
return this;
},
// Tell this object to stop listening to either specific events ... or
// to every object it's currently listening to.
stopListening: function(obj, name, callback) {
var listeningTo = this._listeningTo;
if (!listeningTo) return this;
var remove = !name && !callback;
if (!callback && typeof name === 'object') callback = this;
if (obj)(listeningTo = {})[obj._listenId] = obj;
for (var id in listeningTo) {
obj = listeningTo[id];
obj.off(name, callback, this);
if (remove || _.isEmpty(obj._events)) delete this._listeningTo[id];
}
return this;
}
};
// Regular expression used to split event strings.
var eventSplitter = /\s+/;
// Implement fancy features of the Events API such as multiple event
// names `"change blur"` and jQuery-style event maps `{change: action}`
// in terms of the existing API.
var eventsApi = function(obj, action, name, rest) {
if (!name) return true;
// Handle event maps.
if (typeof name === 'object') {
for (var key in name) {
obj[action].apply(obj, [key, name[key]].concat(rest));
}
return false;
}
// Handle space separated event names.
if (eventSplitter.test(name)) {
var names = name.split(eventSplitter);
for (var i = 0, l = names.length; i < l; i++) {
obj[action].apply(obj, [names[i]].concat(rest));
}
return false;
}
return true;
};
// A difficult-to-believe, but optimized internal dispatch function for
// triggering events. Tries to keep the usual cases speedy (most internal
// PourOver events have 3 arguments).
var triggerEvents = function(events, args) {
var ev, i = -1,
l = events.length,
a1 = args[0],
a2 = args[1],
a3 = args[2];
switch (args.length) {
case 0:
while (++i < l)(ev = events[i]).callback.call(ev.ctx);
return;
case 1:
while (++i < l)(ev = events[i]).callback.call(ev.ctx, a1);
return;
case 2:
while (++i < l)(ev = events[i]).callback.call(ev.ctx, a1, a2);
return;
case 3:
while (++i < l)(ev = events[i]).callback.call(ev.ctx, a1, a2, a3);
return;
default:
while (++i < l)(ev = events[i]).callback.apply(ev.ctx, args);
return;
}
};
var listenMethods = {
listenTo: 'on',
listenToOnce: 'once'
};
// Inversion-of-control versions of `on` and `once`. Tell *this* object to
// listen to an event in another object ... keeping track of what it's
// listening to.
_.each(listenMethods, function(implementation, method) {
Events[method] = function(obj, name, callback) {
var listeningTo = this._listeningTo || (this._listeningTo = {});
var id = obj._listenId || (obj._listenId = _.uniqueId('l'));
listeningTo[id] = obj;
if (!callback && typeof name === 'object') callback = this;
obj[implementation](name, callback, this);
return this;
};
});
// Aliases for backwards compatibility.
Events.bind = Events.on;
Events.unbind = Events.off;
// Allow the `PourOver` object to serve as a global event bus, for folks who
// want global "pubsub" in a convenient place.
_.extend(PourOver, Events);
// #Collections
//The main kind of object in Pourover. A collection is basically a wrapper around an array of objects.
//It adds collection ids to its members and has support for various ways of retrieving all or a part of
//its members.
PourOver.Collection = function(items, opts) {
if (typeof(items) == "undefined") {
items = [];
}
this.items = [];
this.filters = {};
this.sorts = {};
this.addItems(items);
this.on("change", function() {
_.each(this.filters, function(f) {
if (f.current_query) {
f.current_query.refresh();
}
});
});
this.initialize.apply(this, arguments);
};
_.extend(PourOver.Collection.prototype, PourOver.Events, {
initialize: function() {},
// Force the filters and sorts of a collection to refresh. Generally most useful if you have batched
// up a bunch of silented actions and you want to refresh once at the end.
refresh: function() {
this.trigger("queryChange");
},
// Retrive the objects associated with an array of cids. Like everything in Pourover, the cids must be sorted.
// This is not ususally an issue as you generally will not be calling `collection.get` with an array you
// manually create. You will probably be using the output of some function that keeps it sorted for you.
get: function(cids) {
return PourOver.Collection.prototype.getBy.call(this, "cid", cids, true);
},
// Similar to get, except -- rather than getting items by cid -- you are getting them by [attr_name].
// Here vals is an array of [attr_names]s.
getBy: function(attr_name, vals, sorted) {
if (!_.isArray(vals)) {
var vals = [vals];
}
if (typeof(sorted) == "undefined") {
sorted = false;
}
var low = 0,
high = this.items.length,
lc = 0,
hc = vals.length,
output = [],
items = this.items,
i;
if (sorted === true) {
while (low < high && lc < hc) {
if (vals[lc] == (i = items[low])[attr_name]) {
output.push(i);
low++;
lc++;
} else if (vals[lc] < i[attr_name]) {
lc++;
} else {
low++;
}
}
} else if (sorted == "reverse") {
while (low < high && lc < hc) {
if (vals[lc] == (i = items[low])[attr_name]) {
output.push(i);
low++;
lc++;
} else if (vals[lc] > i[attr_name]) {
lc++;
} else {
low++;
}
}
} else {
while (low < high && lc < hc) {
if (_.include(vals, (i = items[low])[attr_name])) {
output.push(i);
vals = _.without(vals, i[attr_name]);
low++;
lc++;
} else {
low++;
}
}
}
return output;
},
getByFirst: function(attr_name, val, sorted) {
if (typeof(sorted) == "undefined") {
sorted = false;
}
var low = 0,
high = this.items.length,
items = this.items,
output,
i;
if (sorted === true) {
while (low < high) {
if (val == (i = items[low])[attr_name]) {
output = i;
break;
} else if (val < i[attr_name]) {
break;
} else {
low++;
}
}
} else if (sorted == "reverse") {
while (low < high) {
if (val == (i = items[low])[attr_name]) {
output = i;
break;
} else if (val > i[attr_name]) {
break;
} else {
low++;
}
}
} else {
while (low < high) {
if (val == (i = items[low])[attr_name]) {
output = i;
break;
} else {
low++;
}
}
}
return output;
},
// Add items to the collection, triggering the appropriate events to keep all dependent sort and filter sets up-to-date.
addItems: function(i) {
this.trigger("will_change");
if (!_.isArray(i)) {
i = [i];
}
var last_id = this.items.length > 0 ? _.last(this.items).cid + 1 : 0,
new_items;
new_items = _.map(i, function(c) {
var n = PourOver.Item(c);
n.cid = last_id++;
return n;
});
this.items = this.items.concat(new_items);
this.regenerateFilterSets(new_items);
this.trigger("change", _(new_items).pluck("cid"));
},
// Remove items from the collection, triggering the appropriate events to keep all dependent sort and filter sets up-to-date.
// This functionality is only included begrudgingly. Pourover is best for collections that rarely remove members.
// TODO: Optimize
removeItems: function(i, isSorted) {
this.trigger("will_change");
if (typeof(isSorted) === "undefined") {
var isSorted = false;
}
if (!_.isArray(i)) {
var i = [i];
}
if (isSorted) {
i = i.sort(function(a, b) {
return a.cid - b.cid;
});
var new_items = [],
old_items = this.items,
new_length = i.length,
old_length = this.items.length,
newi = 0,
oldi = 0;
while (oldi < old_length) {
if (!newi < new_length) {
new_items = new_items.concat(old_items.slice(oldi));
break;
} else if (old_items[oldi].cid === i[newi].cid) {
newi++;
oldi++;
} else {
new_items.push(old_items[oldi]);
oldi++;
}
}
} else {
var new_items = [],
old_items = this.items,
old_length = this.items.length,
oldi = 0,
delete_cids = _.pluck(i, "cid");
while (oldi < old_length && delete_cids.length > 0) {
if (_.include(delete_cids, old_items[oldi].cid)) {
} else {
new_items.push(old_items[oldi]);
}
oldi++;
}
}
this.items = new_items;
this.regenerateFilterSets();
this.trigger("change", _(i).pluck("cid"));
},
// # Collection filter functions
// All filters are associated to collections rather than views. This allows for multiple views to share the same filter.
// This is especially useful for modal situations in which you can set filters on a grid view that are reflected in the
// one up view as well.
addFilters: function(f) {
var that = this,
new_filters;
if (!_.isArray(f)) {
f = [f];
}
new_filters = _.reduce(f, function(m, i) {
m[i.name] = create(i);
m[i.name].collection = that;
return m;
}, {});
this.filters = _.extend(this.filters, new_filters);
// Bubble all query change events up from the individual filters to the collection. This allows a developers to
// specify events that should be triggered whenever any filter's query is changed.
_.each(new_filters, function(f) {
f.on("queryChange", function() {
that.trigger("queryChange");
});
// All filters precache the result of their filtering. This is the source of pourover's speed optimizations.
f.cacheResults(that.items);
// If a user passes in an `associated_attrs` property on a filter, that filter will re-cache its result whenever
// any object in the collection has an attribute changed. Setting `associated_attrs` is essential for admins or
// other uses in which filterable values can change.
if (f.associated_attrs) {
_.each(f.associated_attrs, function(a) {
that.on("change:" + a, function(objs) {
f.removeFromCache(objs);
f.addCacheResults(objs);
if (f.current_query) {
f.current_query.refresh();
}
});
});
}
});
},
// A shortcut to re-calculate the results of every filter. This is expensive if you do not pass in `new_items`, in which cases
// only the new_items will be cached and the filters updated.
regenerateFilterSets: function(new_items) {
var that = this;
// If no new items are passed in, regenerate filters for all items in the collection
if (typeof(new_items) == "undefined") {
_.each(this.filters, function(f) {
f.cacheResults(that.items);
});
} else {
_.each(this.filters, function(f) {
f.addCacheResults(new_items);
});
}
},
// A shortcut for returning a match object containing all the items in a collection. More on matches below.
getAllItems: function() {
var cids = _.pluck(this.items, "cid");
return new PourOver.MatchSet(cids, this, ["all"]);
},
// Get the currently cached results for the last stateful query on a filter (the last time a `setQuery` was called on that filter.)
// If `empty_default` is set to true, the function will return no items if the filter does not have a current query set. Otherwise,
// the function will return all items in the collection. The former `empty_default` setting is useful when OR-ing filters together, when
// you want an unset filter to represent an unselected dimension. The latter is useful when AND-ing filters together, when you
// want an unset filter to comprise all objects in the collection.
getCurrentFilteredItems: function(filter_name, empty_default) {
if (typeof(empty_default) === "undefined") {
empty_default = false;
}
if (this.filters[filter_name].current_query && this.filters[filter_name].current_query.stack.length > 0) {
return this.filters[filter_name].current_query;
} else {
if (empty_default) {
return new PourOver.MatchSet([], this, []);
} else {
return this.getAllItems();
}
}
},
// The non-stateful way to query a filter. Simply returns the result of the query but does not store the query on the filter.
getFilteredItems: function(filter_name, query) {
var filter = this.filters[filter_name],
possibility;
if (_.isUndefined(filter)) throw "The filter " + filter_name + " does not exist.";
return filter.getFn(query);
},
// # Sort functions
// Sorts, like filters, are generally stored on collections for the same reason that filters are stored on the collection rather than the view.
// However, whereas filters keep track of their own state and this is shared between views, the state of which sort is enabled is stored on the view.
addSort: function(sort) {
var that = this;
this.sorts[sort.name] = sort;
sort.collection = this;
sort.rebuildSort();
this.on("change", function() {
sort.rebuildSort(true);
});
// Like filters, if you set `associated_attrs` on a sort, they will rebuild themselves whenever any item in the collection undergoes a change
// on that attribute.
// TODO: Consider cloning on add. Also, bring in line with addFilter (events or not!?)
if (sort.associated_attrs) {
_.each(sort.associated_attrs, function(a) {
that.on("change:" + a, function(objs) {
sort.rebuildSort();
});
});
}
},
// Add multiple sorts.
addSorts: function(sorts) {
if (typeof(opts) === "undefined") {
opts = {};
}
if (!_.isArray(sorts)) {
sorts = [sorts];
}
var that = this;
_.each(sorts, function(s) {
that.addSort(s);
});
},
// The non-stateful way to retrieve all the items in the collection, sorted.
getSortedItems: function(sort_name) {
var s = this.sorts[sort_name],
that = this,
output;
return s.sort(this.items);
},
// A silly shortcut, pass in a cid and an attribute, retrieve its value. Useful for template helpers.
getItemValue: function(cid, attribute) {
var item = _.find(this.items, function(i) {
return i.cid === Number(cid);
});
return item[attribute];
},
// Update the value of one attribute of one item in the collection.
updateItem: function(cid, attribute, value) {
this.trigger("will_incremental_change");
var item = _.find(this.items, function(i) {
return i.cid === Number(cid);
});
item[attribute] = value;
this.trigger("change:" + attribute, [item]);
this.trigger("incremental_change", [attribute]);
this.trigger("update", "updateItem");
return item.guid;
},
// Delete an attribute of one item in the collection.
removeItemAttribute: function(cid, attribute, value) {
this.trigger("will_incremental_change");
var item = _.find(this.items, function(i) {
return i.cid === Number(cid);
});
delete item[attribute];
this.trigger("change:" + attribute, [item]);
this.trigger("incremental_change", [attribute]);
this.trigger("update", "updateItem");
return item.guid;
},
// Change the value of one attribute of many items to the same value.
batchUpdateItems: function(cids, attribute, value) {
this.trigger("will_incremental_change");
var items = this.get(cids, true);
_.each(items, function(i) {
i[attribute] = value;
});
this.trigger("change:" + attribute, items);
this.trigger("incremental_change", [attribute]);
this.trigger("update", "batchUpdate");
return _.pluck(items, "guid");
},
// Change the value of several attributes of a single item in the collection.
updateAttributes: function(cid, updates, silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
this.trigger("will_incremental_change");
var item = _.find(this.items, function(i) {
return i.cid === Number(cid);
});
var that = this;
_.each(updates, function(v, k) {
item[k] = v;
that.trigger("change:" + k, [item]);
});
this.trigger("incremental_change", _.keys(updates));
if (!silent) {
this.trigger("update", "updateAttribute");
}
return item.guid;
},
// Change the value of several attributes of several items in the collection. Here 'updates'
// is a hash of attributes -> new values.
batchUpdateAttributes: function(cids, updates, silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
this.trigger("will_incremental_change");
var items = this.get(cids, true);
var that = this;
_.each(items, function(item) {
_.each(updates, function(v, k) {
item[k] = v;
});
});
_.each(updates, function(v, k) {
that.trigger("change:" + k, items);
});
this.trigger("incremental_change", _.keys(updates));
if (!silent) {
this.trigger("update", "batchUpdate");
this.trigger("batchUpdateAttribute");
}
return _.pluck(items, "guid");
},
batchLoadItems: function(data) {
this.trigger("will_incremental_change");
var new_cids = [],
guids = _.pluck(data, "guid"),
old_items = this.getBy("guid", guids),
old_item_dict = {};
_(old_items).each(function(item) {
old_item_dict[item.guid] = item;
});
_.each(data, _.bind(function(d) {
var item = old_item_dict[d.guid],
last_id = this.items.length > 0 ? _(this.items).last().cid + 1 : 0,
current_item;
if (item) {
current_item = item;
// update the item's attrs
_.each(d, function(v, k) {
current_item[k] = v;
});
} else {
item = PourOver.Item(d);
item.cid = last_id++;
new_cids.push(item.cid);
this.items = this.items.concat([item]);
// add this to the list of existing items so that
// if data contains the same guid multiple times,
// the second instance will be treated as an update.
old_items[item.guid] = item;
}
}, this));
this.regenerateFilterSets();
this.trigger("incremental_change", "*");
this.trigger("change", new_cids);
this.trigger("update", "batchLoad");
this.trigger("batchLoadItems");
}
});
// #Items
// If we ever need to add properties to items in a collection, the code would go here.
PourOver.Item = function(i) {
return i;
},
// #Filters
// A filter is basically a rule for mapping items of a collection into groups based on attribute
// values. It caches the results and can be queried either statefully or non-statefully, depending
// on developer preference.
PourOver.Filter = function(name, values, opts) {
if (typeof(opts) === "undefined") {
opts = {};
}
this.name = name;
this.possibilities = this.createPossibilities(values);
this.values = _.pluck(values, "value");
_.extend(this, opts);
this.initialize.apply(this, arguments);
};
_.extend(PourOver.Filter.prototype, PourOver.Events, {
// Initialize is a no-op by default.
initialize: function() {},
// Given an array of possible values, initializes the object that will store the cached results
// of querying for that possibility.
create_possibilities: function(a) {
deprecationWarn("create_possibilities", "createPossibilities");
return this.createPossibilities(a);
},
createPossibilities: function(vs) {
var o = {};
_.each(vs, function(v) {
var name = v.name || String(v.value);
o[name] = v;
o[name].matching_cids = [];
});
return o;
},
// cacheResults and addCacheResults are generic methods that are must be overridden before instantiating a filter.
// The preset filters included below provide good examples of how these functions should be written. cacheResults
// should cache all the items in the collection, whereas addCacheResults incrementally adds new items to already
// cached, filtered results.
cacheResults: function(items) {
throw "No cache function has been defined for this filter '" + this.name + "'.";
},
addCacheResults: function(items) {
throw "No add cache function has been defined for this filter '" + this.name + "'.";
},
makeQueryMatchSet: function(cids, query) {
return new PourOver.MatchSet(cids, this.getCollection(), [
[this, query]
]);
},
// Generally only used when removing items from a collection or when an item changes value. This will remove the item from
// the cache so that it can either be recached with its new value or thrown away.
removeFromCache: function(items) {
var cids = _.pluck(items, "cid").sort(function(a, b) {
return a - b;
});
_.each(this.possibilities, function(p) {
p.matching_cids = PourOver.subtractSort(p.matching_cids, cids);
});
},
// The stateful way to query a filter. Delegates the retrieval of a MatchSet to the filter's getFn and caches the results on the filter.
query: function(q, silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
var match_set = this.getFn(q);
this.setQuery(match_set, silent);
},
// Assigns a MatchSet to a filter (caches the result) and triggers the appropriate events.
setQuery: function(q, silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
this.current_query = q;
if (!silent) {
this.trigger("queryChange");
}
},
// Removes a cached result from a filter.
clearQuery: function(silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
this.current_query = false;
if (!silent) {
this.trigger("queryChange");
}
},
// Unions a cached result with another result (both being MatchSets) and produces a new MatchSet.
unionQuery: function(q, silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
if (typeof(q) === "string" || typeof(q) === "number" || _.isArray(q)) {
var q = this.getFn(q);
}
if (this.current_query) {
this.current_query = this.current_query.or(q);
} else {
this.current_query = q;
}
if (!silent) {
this.trigger("queryChange");
}
},
// Intersects a cached result with another result (both being MatchSets) and produces a new MatchSet.
intersectQuery: function(q, silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
if (typeof(q) === "string" || typeof(q) === "number" || _.isArray(q)) {
var q = this.getFn(q);
}
if (this.current_query) {
this.current_query = this.current_query.and(q);
} else {
this.current_query = q;
}
if (!silent) {
this.trigger("queryChange");
}
},
// Subtracts a cached result with another result (both being MatchSets) and produces a new MatchSet.
subtractQuery: function(q, silent) {
if (typeof(silent) === "undefined") {
var silent = false;
}
if (typeof(q) === "string" || typeof(q) === "number" || _.isArray(q)) {
var q = this.getFn(q);
}
if (this.current_query) {
this.current_query = this.current_query.not(q);
} else {
this.current_query = q;
}
if (!silent) {
this.trigger("queryChange");
}
},
// This is the inverse of the three functions above. Removes a query from a compound, cached MatchSet on a filter.
// This is useful when you have a UI in which subsequent selections union together. It is faster on a toggle to remove
// the deselected possibility rather than re-union the remaining selected ones.
// TODO: Test
removeSingleQuery: function(q, silent) {
if (!this.current_query) {
return false;
}
if (typeof(silent) === "undefined") {
var silent = false;
}
if (typeof(q) === "string" || typeof(q) === "number" || _.isArray(q)) {
var q = this.getFn(q);
}
var s = [],
stack = this.current_query.stack,
new_stack,
is_compound = function(c) {
return _.isString(c) && c.match(/^(or|and|not)$/);
};
new_stack = _.reduce(stack, function(m, i) {
if (i[1] === q.stack[0][1]) {
return m;
} else if (is_compound(i[0]) && i[1][0][1] === q.stack[0][1]) {
return m;
} else {
m.push(i);
return m;
}
}, s);
if (new_stack[0] && (new_stack[0][0] == "and" || new_stack[0][0] == "or" || new_stack[0][0] == "not")) {
new_stack[0] = new_stack[0][1][0];
}
this.current_query.stack = new_stack;
this.current_query.refresh();
if (!silent) {
this.trigger("queryChange");
}
},
// Convenice method for getting the collection attached to a filter.
// Just an aesthetic thing. I like the explicit "getCollection" calls in
// the rest of the code.
getCollection: function() {
return this.collection;
},
getByPossibilityGroups: function() {
var collection = this.collection;
return _.reduce(this.possibilities, function(m, p, k) {
m[k] = collection.get(p.matching_cids);
return m;
}, {});
}
});
// #Sorts
//
// Sorts cache different orderings of collection items and subsets thereof. Sorts generally belong to collections,
// but they can belong to views as well for optimization concerns.
PourOver.Sort = function(name, opts) {
this.name = name;
_.extend(this, opts);
this.initialize.apply(this, arguments);
};
_.extend(PourOver.Sort.prototype, PourOver.Events, {
initialize: function() {},
// By default, sorts are not view sorts. A view sort is attached to a specific view and only updates when that
// view undergoes a queryChange.
view: false,
// Use a sort to order an array of cids
sort: function(set) {
return PourOver.permuteFromArray(set, this.permutation_array);
},
// Recache the results of sorting the collection.
rebuild_sort: function(new_items) {
deprecationWarn("rebuild_sort", "rebuildSort");
this.rebuildSort(new_items);
},
rebuildSort: function(new_items) {
if (typeof(new_items) === "undefined") {
new_items = false;
}
if (this.view) {
var items = this.view.match_set.all();
} else {
var items = this.collection.items;
}
this.permutation_array = PourOver.buildPermutationArray(items, this);
this.trigger("resort", new_items);
}
});
// #Views
//
// Views store a state of collection and are generally what should be rendered. There can be many views per collection.
// Views can be paged. Moreover, a view has a selection function which tells the view how to compose its various filters to produce the current set.
PourOver.View = function(name, collection, opts) {
var that = this;
this.name = name;
if (typeof(opts) === "undefined") {
opts = {};
}
this.collection = collection;
this.match_set = new PourOver.MatchSet(_.pluck(this.collection.items, "cid"), this.collection, ["all"]);
if (opts.template) {
this.template = opts.template;
}
// Whenever the collection gains or loses members, recache the MatchSet saved on the view.
this.collection.on("will_change will_incremental_change", function() {
that.storeViewPosition();
});
this.collection.on("change", function() {
that.match_set.refresh();
that.setNaturalSelection();
that.resetPage();
that.trigger("collection-change");
});
// Whenever an item in the collection is changed, recache the MatchSet saved on the view.
this.collection.on("incremental_change", function(attrs) {
that.match_set.refresh();
that.setNaturalSelection(attrs);
that.resetPage();
that.trigger("collection-incremental-change");
});
// Bubble all collection update events through.
this.collection.on("update", function(f) {
that.trigger("update", f);
});
// Whenever any filter is queried statefully, reset the view's MatchSet; We don't have to refresh the match_set here. That is only necessary
// when it's possible that a filter has stale information as a result of a change in the underlying data.
this.collection.on("queryChange", function() {
that.setNaturalSelection();
that.trigger("update", "query");
});
// Bubble up sortChange events as updates
this.on("sortChange", function() {
this.trigger("update", "sort");
});
// Bubble up pageChange events as updates
this.on("pageChange", function() {
this.trigger("update", "page");
});
this.view_sorts = [];
_.extend(this, opts);
this.initialize.apply(this, arguments);
};
_.extend(PourOver.View.prototype, PourOver.Events, {
initialize: function() {},
current_page: 0,
// By default, return all items in the view.
page_size: Infinity,
current_sort: false,
// Changes a view from being sorted to no longer being sorted.
removeSort: function() {
if (this.current_sort.off) {
this.current_sort.off("resort");
}
this.current_sort = false;
this.trigger("sortChange");
},
// Sets a sort on a view and fires all appropriate events.
setSort: function(sort_name, view_sort, silent) {
if (typeof(view_sort) === "undefined") {
view_sort = false;
}
if (typeof(silent) === "undefined") {
silent = false;
}
var that = this;
if (this.current_sort.off) {
this.current_sort.off("resort");
}
if (sort_name && view_sort) {
this.current_sort = this.view_sorts[sort_name];
this.current_sort.on("resort", _.bind(function(new_items) {
if (!(this.silent_sort && new_items)) {
that.trigger("sortChange");
}
}, this));
} else if (sort_name) {
this.current_sort = this.collection.sorts[sort_name];
this.current_sort.on("resort", _.bind(function(new_items) {
if (!(this.silent_sort && new_items)) {
that.trigger("sortChange");
}
}, this));
} else {
this.current_sort = false;
}
if (!silent) {
this.trigger("sortChange");
}
},
// Return the name of the current sort of the view.
getSort: function() {
if (!this.current_sort) {
return false;
} else {
return this.current_sort.name;
}
},
// Add a sort to the view. The difference between this and a collection sort is that this sort will
// only change if the view receives a selectionChange.
addViewSorts: function(sorts) {
if (typeof(opts) === "undefined") {
opts = {};
}
if (!_.isArray(sorts)) {
sorts = [sorts];
}
var that = this;
_.each(sorts, function(sort) {
that.view_sorts[sort.name] = sort;
sort.collection = that.collection;
sort.view = that;
sort.rebuildSort();
that.on("selectionChange", function(attrs) {
if (sort.associated_attrs === undefined || attrs === "*") {
sort.rebuildSort();
}
if (sort.associated_attrs && _.intersection(sort.associated_attrs, attrs).length > 0) {
sort.rebuildSort();
}
});
});
},
// IMPORTANT: This determines how a view composes the filters on a collection to generate results. Here, by default,
// every filter on the collection is intersected. This is often the desired behavior. However, this must be overridden
// if you want your view to do fancier things such as union some filters, difference others, and intersect the rest.
selectionFn: function() {
var collection = this.collection;
if (_.isEmpty(collection.filters)) {
return collection.getAllItems();
}
var output = _.reduce(collection.filters, function(m, i) {
var q = i.current_query;
if (m && (!q || _.isEmpty(q.stack))) {
return m;
}
if (!m && (!q || _.isEmpty(q.stack))) {
return collection.getAllItems();
}
if (m) {
return m.and(q);
} else {
return q;
}
}, false);
return output;
},
// Caches a MatchSet on the view as the current match_set;
setSelection: function(match_set, attrs) {
this.match_set = match_set;
this.trigger("selectionChange", attrs);
},
// Delegates to the views selectionFn to generate an array of valid cids given the current filters.
setNaturalSelection: function(attrs) {
var selection;
selection = this.selectionFn();
this.setSelection(selection, attrs);
},
// Removes a MatchSet from a view and replaces it with the universe of possible items.
clearSelection: function() {
this.match_set = this.collection.getAllItems();
},
// IMPORTANT: This is the function you will call most often on views. This returns the cached, filtered items and
// then sorts them and pages them as appropriate.
getCurrentItems: function(page) {
if (!this.match_set) {
return [];
}
if (typeof(page) === "undefined") {
var page = this.current_page;
}
if (this.page_size == Infinity) {
if (this.current_sort) {
var items = this.match_set.allSorted(this.current_sort);
} else {
var items = this.match_set.all();
}
} else {
// TODO: Slice cids before reassociating
if (this.current_sort) {
var items = this.match_set.allSortedCids(this.current_sort);
items = items.slice(this.page_size * page, this.page_size * (page + 1));
var ordered_cids = _.clone(items).sort(function(a, b) {
return a - b;
});
var unsorted_items = this.collection.get(ordered_cids);
items = _.map(items, function(i) {
return _.findWhere(unsorted_items, {
cid: i
});
});
} else {
var items = this.match_set.cids;
items = items.slice(this.page_size * page, this.page_size * (page + 1));
items = this.collection.get(items);
}
}
return items;
},
storeViewPosition: function() {
var head_item = this.getCurrentItems()[0];
if (head_item) {
this.last_head_cid = head_item.cid;
}
},
resetPage: function() {
if (this.last_head_cid) {
if (this.current_sort) {
this.current_sort.rebuildSort();
}
this.pageTo(this.last_head_cid, true);
}
this.last_head_cid = undefined;
},
// Change the page of the view by [dir] pages. Negative values to page back.
page: function(dir) {
var new_dir = dir + this.current_page;
if (new_dir < 0) new_dir = 0;
if (new_dir > Math.ceil(this.match_set.length() / this.page_size - 1)) new_dir = Math.ceil(this.match_set.length() / this.page_size - 1);
this.current_page = new_dir;
this.trigger("pageChange");
},
// Page to a specific cid.
pageTo: function(cid, silent) {
if (typeof(silent) == "undefined") {
var silent = false;
}
if (this.current_sort) {
var index = _.indexOf(this.match_set.allSortedCids(this.current_sort), cid),
len = this.match_set.cids.length,
page = Math.floor(index / this.page_size);
} else {
var index = _.indexOf(this.match_set.cids, cid),
len = this.match_set.cids.length,
page = Math.floor(index / this.page_size);
}
if (index >= 0) {
this.current_page = page;
if (!silent) {
this.trigger("pageChange");
}
}
},
// Change the page of the view to a specific page.
setPage: function(page) {
if (page < 0) page = 0;
if (page > Math.ceil(this.match_set.length() / this.page_size - 1)) page = Math.ceil(this.match_set.length() / this.page_size - 1);
this.current_page = page;
this.trigger("pageChange");
},
// Set the page size.
setPageSize: function(size) {
this.page_size = size;
this.trigger("pageChange");
},
render: function() {}
});
// #MatchSets
//
// These are what are returned from queries on filters. They can be chained together with ands, or, & nots.
// They also keep a "stack" to remember how they were created (afte