API Docs for:
Show:

File: packages/ember-crossfilter/ember-crossfilter.js

/**
 * @module EmberCrossfilter
 * @class EmberCrossfilter
 * @type Ember.Mixin
 * Responsible for converting the loaded content into a Crossfilter object.
 */
window.EmberCrossfilter = Ember.Mixin.create({

    /**
    * @property _crossfilter
    * @type {Object}
    * @private
    */
    _crossfilter: null,

    /**
     * @property _deletedModels
     * @type {Array}
     * Holds a list of models that have been deleted.
     * @private
     */
    _deletedModels: [],

    /**
     * @property allowTiming
     * @type {Boolean}
     * Can be overridden by the class to allow for timing details to be output.
     */
    allowDebugging: false,

    /**
     * @property actions
     * @type {Object}
     */
    actions: {

        /**
         * @method clearAllFilters
         * Clears all of the filters that are currently active.
         * @return {void}
         */
        clearAllFilters: function clearAllFilters() {

            var start = new Date().getTime();

            // Loop through all of the configured dimensions.
            for (var key in this.filterMap) {

                if (!this.filterMap.hasOwnProperty(key)) {
                    continue;
                }

                // Find the map and the dimension by the current key.
                var map         = this.filterMap[key],
                    dimension   = this['_dimension%@'.fmt(map.dimension.capitalize())];

                // Clear the applied Crossfilter.
                dimension.filterAll();

                // Clear the `active` flag and reset its value.
                Ember.set(map, 'active', false);
                map.value = null;

                if (this.isBooleanFilter(map)) {
                    // If we're dealing with a `filterAnd`/`filterOr`, then its default is 0.
                    map.value = 0;
                }

            }

            // Update the changes with all of the filters removed.
            this._applyContentChanges();

            if (this.allowDebugging) {
                // Used for debugging purposes.
                Ember.debug('Clearing All: %@ millisecond(s)'.fmt(new Date().getTime() - start));
            }

        },

        /**
         * @method addRecord
         * @param record {Object}
         * Adds a record to the Crossfilter.
         * @returns {Boolean}
         */
        addRecord: function addRecord(record) {
            this._crossfilter.add([record]);
            this._applyContentChanges();
            return true;
        },

        /**
         * @method addRecords
         * @param records {Array}
         * Wrapper method for adding many records to the Crossfilter.
         * @return {Number}
         */
        addRecords: function addRecords(records) {

            var added = 0;

            if (!Array.isArray(records)) {
                console.error('You must pass an array of records: use `addRecord` instead!');
                return 0;
            }

            // Iterate over all of the records and add each one individually.
            for (var index = 0, count = records.length; index <= count; index++) {

                if (!records.hasOwnProperty(index)) {
                    continue;
                }

                // Add each record we come across!
                var record = records[index];
                this.send('addRecord', record);
                added++;

            }

            return added;

        },

        /**
         * @method deleteRecord
         * @param record {Object}
         * Deletes a record from the Crossfilter.
         * @returns {Boolean}
         */
        deleteRecord: function addRecord(record) {
            this._deletedModels.push(record);
            this._applyContentChanges();
            return true;
        },

        /**
         * @method deleteRecords
         * @param records {Array}
         * Wrapper method for deleting items from the Crossfilter.
         * @return {Number}
         */
        deleteRecords: function deleteRecords(records) {

            if (!Array.isArray(records)) {
                console.error('You must pass an array of records: use `deleteRecord` instead!');
                return 0;
            }

            // Iterate over all of the records and delete each one individually.
            for (var index = 0, count = records.length; index <= count; index++) {

                if (!records.hasOwnProperty(index)) {
                    continue;
                }

                // Remove each record we come across!
                var record = records[index];
                this.deleteRecord(record);

            }

            return records.length;

        },

        /**
         * @method sortContent
         * Sorts the content based on the property, and whether it should be ascending/descending.
         * @param property {String}
         * @param isAscending {Boolean}
         * @return {void}
         */
        sortContent: function sortContent(property, isAscending) {

            // Sort the content and then place it into the content array.
            var content = this._sortedContent(Ember.get(this, 'content'), property, isAscending),
                start   = new Date().getTime();

            Ember.set(this, 'content', content);

            // Change the controller's variables so that you can see what's active.
            Ember.assert('In order to sort you must have a `sort` object defined.', !!Ember.get(this, 'sort'));
            Ember.assert('You must define `sortProperty` in your `sort` object.', !!Ember.get(this, 'sort.sortProperty'));
            Ember.set(this, 'sort.sortProperty', property);
            Ember.set(this, 'sort.isAscending', isAscending);

            // Notify that we've rearranged the content, otherwise there will be no update.
            this.notifyPropertyChange('content');

            if (this.allowDebugging) {
                // Debugging information.
                Ember.debug('Sorting: %@ millisecond(s)'.fmt(new Date().getTime() - start));
            }

        },

    },

    /**
     * @method init
     * Invoked when the controller is instantiated.
     * @constructor
     */
    init: function init() {

        this._super();

        // Add the observer to create the Crossfilter when we have some content.
        Ember.addObserver(this, 'content.length', this, '_createCrossfilter');

        // Create the Crossfilter.
        this._createCrossfilter();

    },

    /**
     * Determines if a particular filter is active or not.
     * @param key {String}
     * @param value {String|Number}
     * @return {Boolean}
     */
    isActiveFilter: function isActiveFilter(key, value) {

        // Find the relevant `filterMap`.
        var map = this.filterMap[key];

        // If we're dealing with a `filterAnd`/`filterOr`, then we need to perform a
        // small calculation on it.
        if (this.isBooleanFilter(map)) {

            // Gather the bitwise value.
            var bitwiseValue = map._mapProperties[value];

            if (this.getBooleanType(map) === 'or') {
                return Boolean((map.value & bitwiseValue));
            }

            return $.inArray(bitwiseValue, map.value) !== -1;

        }

        // Otherwise the `active` property will tell us.
        return Ember.get(map, 'active') === true;

    },

    /**
     * @method isBooleanFilter
     * @param map {Object}
     * @return {Boolean}
     */
    isBooleanFilter: function isBooleanFilter(map) {
        return (map.method === 'filterOr' || map.method === 'filterAnd');
    },

    /**
     * @method getBooleanType
     * @param map {Object}
     * @return {String}
     */
    getBooleanType: function getBooleanType(map) {
        return (map.method === 'filterOr') ? 'or' : 'and';
    },

    /**
     * @method addFilter
     * @param key
     * @param value
     * Applies a filter to one of our pre-defined dimensions.
     * @return {void}
     */
    addFilter: function addFilter(key, value) {

        // Find the map we're referencing by its name, and extract its method.
        var map = this.filterMap[key];

        if (!this.isBooleanFilter(map)) {

            // If we're dealing with a native Crossfilter method then we just need
            // to set the value, and enable the `active` property.
            map.value = value;
            Ember.set(map, 'active', true);

        } else {

            // Otherwise we're dealing with a `filterAnd`/`filterOr`.
            // Firstly we need to push the value into the list of `active` elements,
            // and ensure it's unique.

            map.active.pushObject(value);
            map.active = map.active.uniq();

            if (this.getBooleanType(map) === 'or') {

                // If the boolean is "OR" then we need to add the bitwise.
                map.value |= map._mapProperties[value];

            } else {

                if (!Ember.isArray(map.value)) {
                    // Make it into an array if it isn't already.
                    map.value = [];
                }
                // Otherwise it needs to be placed into an array, so that each bitwise
                // value can be checked separately.
                map.value.push(map._mapProperties[value]);

            }
        }

        // Finally we can begin to update the content in the controller.
        this._updateContent(map);

    },

    /**
     * @method removeFilter
     * @param key
     * @param value
     * Clear the any applied filters to the dimension.
     * @return {void}
     */
    removeFilter: function removeFilter(key, value) {

        // Find the `filterMap` that relates to this key.
        var map = this.filterMap[key];

        if (!this.isBooleanFilter(map)) {

            // If we're not dealing with a `filterAnd`/`filterOr` then we can
            // set its value to false, and it's `active` as well.
            map.value = false;
            Ember.set(map, 'active', false);

        }

        if (this.isBooleanFilter(map)) {

            // Otherwise we'll need to take the value out of the list
            // of active values, and ensure it's unique.
            map.active.removeObject(value);
            map.active = map.active.uniq();

            if (this.getBooleanType(map) === 'or') {

                // If we're dealing with an "OR" then it needs to be deducted
                // from the current bitwise.
                if (map.value & map._mapProperties[value]) {
                    map.value ^= map._mapProperties[value];
                }

            } else {

                // Otherwise we simply take it out of the array.
                var index = map.value.indexOf(map._mapProperties[value]);
                map.value.splice(index, 1);

            }

        }

        // Voila!
        this._updateContent(map);

    },

    /**
     * @method top
     * Helper method to find the highest value.
     * @param property {String}
     * @param count {Number}
     * @return {Number|String}
     */
    top: function top(property, count) {
        return this._topBottom(property, count, 'top');
    },

    /**
     * @method bottom
     * Helper method to find the lowest value.
     * @param property {String}
     * @param count {Number}
     * @return {Number|String}
     */
    bottom: function bottom(property, count) {
        return this._topBottom(property, count, 'bottom');
    },

    /**
     * @method _topBottom
     * @param key {String}
     * @param count {Number}
     * @param crossfilterMethod {String}
     * @return {Number|String}
     * @private
     */
    _topBottom: function _topBottom(key, count, crossfilterMethod) {

        // Assert that we have a `filterMap` by this key.
        Ember.assert('Dimension with key "%@" is not defined.'.fmt(key), !!this.filterMap[key]);

        // Find the map and the related dimension.
        var map         = this.filterMap[key],
            dimension   = '_dimension%@'.fmt(map.dimension.capitalize());

        console.log(this.get('_dimensionCuteness'));

        // Use Crossfilter method to find the top/bottom.
        return this[dimension][crossfilterMethod](count || 1)[0];

    },

    /**
     * @method _createCrossfilter
     * Creates the Crossfilter from the content.
     * @return {Boolean}
     * @private
     */
    _createCrossfilter: function _createCrossfilter() {

        // Assert that we have the `filterMap` property for configuring EmberCrossfilter.
        Ember.assert('Controller implements EmberCrossfilter but `filterMap` has not been specified.', !!this.filterMap);

        // Create the Crossfilter, and then create the dimensions.
        var content = Ember.get(this, 'content');

        // Checks whether we have a defined controller, and/or no content.
        var hasDefinedCrossfilter   = !!this._crossfilter,
            hasNoContent            = !content.length;

        // If we don't want have any content yet, or a defined Crossfilter, then either
        // the content hasn't been loaded yet, or we've already created the Crossfilter.
        if (hasNoContent || hasDefinedCrossfilter) {
            return false;
        }

        // Remove the observer because we don't want to keep triggering this method when
        // the content updates.
        Ember.removeObserver(this, 'content.length', this, '_createCrossfilter');

        // Create the Crossfilter and its related dimensions.
        this._crossfilter = crossfilter(content);
        this._createDimensions();

        if (Ember.get(this, 'sort.sortProperty')) {

            // Gather the details for the sorting.
            var sortProperty    = Ember.get(this, 'sort.sortProperty'),
                sortAscending   = Ember.get(this, 'sort.isAscending');

            // If we have a sort.sortProperty then we can sort the content straight away.
            Ember.set(this, 'content', this._sortedContent(content, sortProperty, sortAscending));

        }

        return true;

    },

    /**
     * Update the content in the controller against the applied filters.
     * @param map
     * @return {void}
     * @private
     */
    _updateContent: function _updateContent(map) {

        // Find the defined dimension name, and begin the timing.
        var start       = new Date().getTime(),
            dimension   = this['_dimension%@'.fmt(map.dimension.capitalize())];

        switch (map.method) {

            // Use the jQuery inArray method if we've defined a `filterAnd`/`filterOr`.
            case ('filterOr'): case ('filterAnd'): this._setFilterBoolean(map, dimension); break;

            // Invoked when we're handling a filterRange dimension.
            case ('filterRangeMin')  : this._setFilterRangeMin(map, dimension); break;
            case ('filterRangeMax')  : this._setFilterRangeMax(map, dimension); break;

            // We need to apply a special callback if we're dealing with a filterFunction.
            case ('filterFunction') : this._setFilterFunction(map, dimension); break;

            // Otherwise we can use the old-fashioned Crossfilter method.
            default                 : dimension[map.method](map.value); break;

        }

        // Update the "content" array to reflect the new changes.
        this._applyContentChanges();

        if (this.allowDebugging) {
            // Used for debugging purposes.
            Ember.debug('Filtering: %@ millisecond(s)'.fmt(new Date().getTime() - start));
        }

    },

    /**
     * @method _applyContentChanges
     * Updates the content array based on the applied filters. Any changes to the Crossfilter should
     * mean invoke this function!
     * @return {void}
     * @private
     */
    _applyContentChanges: function _applyContentChanges() {

        // Gather the default dimension, and apply the default dimension on the primary key.
        var defaultDimension    = Ember.get(this, '_dimensionDefault'),
            deletedModelIds     = this._deletedModels.map(function(model) {
                if (typeof model === 'undefined') {
                    return false;
                }

                return (model[Ember.get(this, 'primaryKey') || 'id']);
            }),
            deleted             = function(primaryKey) {
                return ($.inArray(primaryKey, deletedModelIds) === -1);
            },
            content             = defaultDimension.filterFunction(deleted).top(Infinity);

        if (Ember.get(this, 'sort.sortProperty')) {
            // Sort the content if the user has defined the `sort` object.
            content = this._sortedContent(content, Ember.get(this, 'sort.sortProperty'), Ember.get(this, 'sort.isAscending'));
        }

        // Finally we can update the content of the controller.
        Ember.set(this, 'content', content);

    },

    /**
     * @method _createDimensions
     * Create the defined dimensions from the controller.
     * @return {void}
     * @private
     */
    _createDimensions: function _createDimensions() {

        /**
         * @method defineProperty
         * Wrapper for the Object.defineProperty, as all of our defined dimensions will
         * be similar in their construction.
         * @param name
         * @param property
         * @return {void}
         */
        var defineProperty = function defineProperty(name, property) {

            if (this[name]) {
                // We've already defined this dimension (probably a filterRange).
                return;
            }

            // Define the property using the JS 1.8.5 way.
            Object.defineProperty(this, name, {
                enumerable      : false,
                configurable    : false,
                writable        : false,
                value           : this._crossfilter.dimension(function(d) {
                    return d[property];
                })
            });

        };

        // Define our default dimension, which is the primary key of the collection (id).
        defineProperty.apply(this, ['_dimensionDefault', Ember.get(this, 'primaryKey') || 'id']);

        for (var map in this.filterMap) {

            if (!this.filterMap.hasOwnProperty(map)) {
                continue;
            }

            // Add the name property to the filterMap method for using in setFilterRangeMin/setFilterRangeMax.
            this.filterMap[map].name = map;

            // Reduce this iteration to a simpler variable.
            map = this.filterMap[map];

            // Define the value on the `filterMap`.
            map.value = null;

            Ember.set(map, 'active', false);

            if (this.isBooleanFilter(map)) {

                Ember.set(map, 'active', []);
                // We need to apply some special behaviour if it's a `filterAnd`/`filterOr`.
                this._createFilterBoolean(map);

            }

            // Define the defined dimension in the controller.
            var name = '_dimension%@'.fmt(map.dimension.capitalize());
            defineProperty.apply(this, [name, map.property]);

        }

    },

    /**
     * @method _createFilterBoolean
     * @param map {Object}
     * Responsible for setting up the `filterAnd`/`filterOr` methods by attaching a bitwise operator
     * to each model.
     * @private
     */
    _createFilterBoolean: function _createFilterBoolean(map) {
        
        var start = new Date().getTime();

        // Initialise all of the variables, and find a unique list of the properties
        // in the models for this property.
        var allProperties   = this.mapProperty(map.property),
            properties      = [].concat.apply([], allProperties).uniq(),
            propertyName    = map.property,
            propertiesMap   = {},
            totalBitwise    = 0,
            currentIndex    = 0;

        // Loop through all of the unique properties from the controller's models.
        for (var uniquePropertyIndex in properties) {

            if (!properties.hasOwnProperty(uniquePropertyIndex)) {
                // Don't continue if it's not in the immediate prototype.
                continue;
            }

            // Otherwise we can assign a unique bitwise to this property, and increment
            // the total bitwise.
            var propertyBitwise = (1 << currentIndex++);
            totalBitwise ^= propertyBitwise;

            // Finally we can define the property's bitwise, and place it into a convenient object.
            propertiesMap[properties[uniquePropertyIndex]] = propertyBitwise;
        }

        // Set the items on the relevant `filterMap`.
        map.property        = '__ecBitwise%@'.fmt(map.name.capitalize());
        map.value           = 0;
        map._totalBitwise   = totalBitwise;
        map._mapProperties  = propertiesMap;

        var content         = Ember.get(this, 'content');

        // Iterate over all of the models in the current controller.
        for (var modelIndex = 0, numModels = Ember.get(this, 'content.length'); modelIndex <= numModels; modelIndex++) {

            // Find the model based on the current `modelIndex`.
            var model = content.objectAt(modelIndex);

            if (!model) {
                // If we don't have a model, then we can't continue.
                continue;
            }

            // Find the desired properties for this model, and initialise its bitwise.
            var propertiesList  = Ember.get(model, propertyName),
                itemBitwise     = 0;

            // Loop through each of the individual properties defined in this model, based on the property
            // we care about from the `filterMap`.
            for (var propertyIndex = 0, numItems = propertiesList.length; propertyIndex <= numItems; propertyIndex++) {

                // Find the actual value of the property.
                var propertyValue = propertiesList[propertyIndex];
    
                if (!propertyValue) {
                    // If it's empty then we don't want it.
                    continue;
                }

                // Otherwise we can incrementally calculate this model's bitwise based on
                // the properties it has.
                itemBitwise ^= map._mapProperties[propertyValue];

            }

            // Finally we can set the __ecBitwise* property on the model for later reference.
            Object.defineProperty(model, map.property, {
                enumerable: false,
                configurable: true,
                writable: false,
                value: itemBitwise
            });

        }

        if (this.allowDebugging) {
            // Calculate how long it took to create this bitwise stuff.
            Ember.debug('Properties: %@ millisecond(s)'.fmt(new Date().getTime() - start));
        }
    
    },

    /**
     * @method _sortedContent
     * @param content {Array}
     * @param property {String}
     * @param isAscending {Boolean}
     * @return {String}
     * @private
     */
    _sortedContent: function _sortedContent(content, property, isAscending) {

        // Initialise the sorting using Crossfilter's `quicksort`.
        var sortAlgorithm   = crossfilter.quicksort.by(function(d) { return d[property]; });

        // Sort the content using Crossfilter.
        var sorted = sortAlgorithm(content, 0, content.length);

        if (!isAscending) {
            // If we want it in descending order, then we need to reverse the array.
            sorted = sorted.reverse();
        }

        return sorted;

    },

    /**
     * @method _setFilterBoolean
     * @param map
     * @param dimension
     * Implement a missing Crossfilter method for checking the inArray, although
     * if you have a small array, then you might be better off using bitwise
     * against the filterFunction method.
     * @return {void}
     * @private
     */
    _setFilterBoolean: function _setFilterBoolean(map, dimension) {

        if (this.getBooleanType(map) === 'and') {

            dimension.filterFunction(function(d) {

                if (map.value === 0) {
                    // If the value is zero, then we'll return `true` so that
                    // no items get removed using this filter.
                    return true;
                }

                var hasAllValues = true;

                // Loop through ALL of the values set on this filter.
                map.value.forEach(function(value) {

                    // ...And ensure that each one is in the model.
                    if ((d & value) === 0) {

                        // If not, then it fails the AND boolean.
                        hasAllValues = false;
                        return false;

                    }

                });

                // Whether or not this model has all of the items we're after.
                return hasAllValues;

            });

            return;

        }

        dimension.filterFunction(function(d) {

            // If the value is zero, then we'll return `true` so that
            // no items get removed using this filter.
            if (map.value === 0) {
                return true;
            }

            // We can then perform a simple bitwise calculation.
            return map.value & d;

        });

    },

    /**
     * @method _setFilterFunction
     * @param map
     * @param dimension
     * Although the filterFunction is similar to filterRange, filterExact, etc... we
     * need to invoke a user callback in order to calculate it. For this we use
     * convention over configuration.
     * @private
     */
    _setFilterFunction: function _setFilterFunction(map, dimension) {

        var controller = this, methodName;

        if (map.value === false) {
            // Remove the custom filter.
            dimension.filterAll();
            return;
        }

        methodName = '_apply%@'.fmt(map.name.capitalize());
        Ember.assert('Crossfilter `filterFunction` expects a callback named `%@`.'.fmt(methodName), !!Ember.canInvoke(this, methodName));

        controller = this;
        dimension.filterFunction(function(d) {
            return controller[methodName].apply(controller, [d]);
        });

    },

    /**
     * @method _setFilterRangeMin
     * @param map
     * @param dimension
     * Checks the corresponding dimension for the minimum value, and then continues to create
     * the array for the filterRange.
     * @private
     */
    _setFilterRangeMin: function _setFilterRangeMin(map, dimension) {

        var minName = map.name.replace('min', 'max'), maxValue;

        // Assert that we can find the opposite dimension.
        Ember.assert('You must specify define the `max` dimension for %@'.fmt(map.name), !!this.filterMap[minName]);

        // Apply the filter using the existing maximum value, if it exists.
        maxValue = this.filterMap[minName].value;
        dimension.filterRange([map.value || -Infinity, maxValue || Infinity]);

    },

    /**
     * @method _setFilterRangeMax
     * @param map
     * @param dimension
     * Checks the corresponding dimension for the maximum value, and then continues to create
     * the array for the filterRange.
     * @private
     */
    _setFilterRangeMax: function _setFilterRangeMax(map, dimension) {

        var maxName = map.name.replace('max', 'min'), minValue;

        // Assert that we can find the opposite dimension.
        Ember.assert('You must specify define the `min` dimension for %@'.fmt(map.name), !!this.filterMap[maxName]);

        // Apply the filter using the existing minimum value, if it exists.
        minValue = this.filterMap[maxName].value;
        dimension.filterRange([minValue || -Infinity, map.value || Infinity]);

    }

});