Source: Core/TimeIntervalCollection.js

/*global define*/
define([
        './binarySearch',
        './defaultValue',
        './defined',
        './defineProperties',
        './DeveloperError',
        './Event',
        './JulianDate',
        './TimeInterval'
    ], function(
        binarySearch,
        defaultValue,
        defined,
        defineProperties,
        DeveloperError,
        Event,
        JulianDate,
        TimeInterval) {
    'use strict';

    function compareIntervalStartTimes(left, right) {
        return JulianDate.compare(left.start, right.start);
    }

    /**
     * A non-overlapping collection of {@link TimeInterval} instances sorted by start time.
     * @alias TimeIntervalCollection
     * @constructor
     *
     * @param {TimeInterval[]} [intervals] An array of intervals to add to the collection.
     */
    function TimeIntervalCollection(intervals) {
        this._intervals = [];
        this._changedEvent = new Event();

        if (defined(intervals)) {
            var length = intervals.length;
            for (var i = 0; i < length; i++) {
                this.addInterval(intervals[i]);
            }
        }
    }

    defineProperties(TimeIntervalCollection.prototype, {
        /**
         * Gets an event that is raised whenever the collection of intervals change.
         * @memberof TimeIntervalCollection.prototype
         * @type {Event}
         * @readonly
         */
        changedEvent : {
            get : function() {
                return this._changedEvent;
            }
        },

        /**
         * Gets the start time of the collection.
         * @memberof TimeIntervalCollection.prototype
         * @type {JulianDate}
         * @readonly
         */
        start : {
            get : function() {
                var intervals = this._intervals;
                return intervals.length === 0 ? undefined : intervals[0].start;
            }
        },

        /**
         * Gets whether or not the start time is included in the collection.
         * @memberof TimeIntervalCollection.prototype
         * @type {Boolean}
         * @readonly
         */
        isStartIncluded : {
            get : function() {
                var intervals = this._intervals;
                return intervals.length === 0 ? false : intervals[0].isStartIncluded;
            }
        },

        /**
         * Gets the stop time of the collection.
         * @memberof TimeIntervalCollection.prototype
         * @type {JulianDate}
         * @readonly
         */
        stop : {
            get : function() {
                var intervals = this._intervals;
                var length = intervals.length;
                return length === 0 ? undefined : intervals[length - 1].stop;
            }
        },

        /**
         * Gets whether or not the stop time is included in the collection.
         * @memberof TimeIntervalCollection.prototype
         * @type {Boolean}
         * @readonly
         */
        isStopIncluded : {
            get : function() {
                var intervals = this._intervals;
                var length = intervals.length;
                return length === 0 ? false : intervals[length - 1].isStopIncluded;
            }
        },

        /**
         * Gets the number of intervals in the collection.
         * @memberof TimeIntervalCollection.prototype
         * @type {Number}
         * @readonly
         */
        length : {
            get : function() {
                return this._intervals.length;
            }
        },

        /**
         * Gets whether or not the collection is empty.
         * @memberof TimeIntervalCollection.prototype
         * @type {Boolean}
         * @readonly
         */
        isEmpty : {
            get : function() {
                return this._intervals.length === 0;
            }
        }
    });

    /**
     * Compares this instance against the provided instance componentwise and returns
     * <code>true</code> if they are equal, <code>false</code> otherwise.
     *
     * @param {TimeIntervalCollection} [right] The right hand side collection.
     * @param {TimeInterval~DataComparer} [dataComparer] A function which compares the data of the two intervals.  If omitted, reference equality is used.
     * @returns {Boolean} <code>true</code> if they are equal, <code>false</code> otherwise.
     */
    TimeIntervalCollection.prototype.equals = function(right, dataComparer) {
        if (this === right) {
            return true;
        }
        if (!(right instanceof TimeIntervalCollection)) {
            return false;
        }
        var intervals = this._intervals;
        var rightIntervals = right._intervals;
        var length = intervals.length;
        if (length !== rightIntervals.length) {
            return false;
        }
        for (var i = 0; i < length; i++) {
            if (!TimeInterval.equals(intervals[i], rightIntervals[i], dataComparer)) {
                return false;
            }
        }
        return true;
    };

    /**
     * Gets the interval at the specified index.
     *
     * @param {Number} index The index of the interval to retrieve.
     * @returns {TimeInterval} The interval at the specified index, or <code>undefined</code> if no interval exists as that index.
     */
    TimeIntervalCollection.prototype.get = function(index) {
        //>>includeStart('debug', pragmas.debug);
        if (!defined(index)) {
            throw new DeveloperError('index is required.');
        }
        //>>includeEnd('debug');

        return this._intervals[index];
    };

    /**
     * Removes all intervals from the collection.
     */
    TimeIntervalCollection.prototype.removeAll = function() {
        if (this._intervals.length > 0) {
            this._intervals.length = 0;
            this._changedEvent.raiseEvent(this);
        }
    };

    /**
     * Finds and returns the interval that contains the specified date.
     *
     * @param {JulianDate} date The date to search for.
     * @returns {TimeInterval|undefined} The interval containing the specified date, <code>undefined</code> if no such interval exists.
     */
    TimeIntervalCollection.prototype.findIntervalContainingDate = function(date) {
        var index = this.indexOf(date);
        return index >= 0 ? this._intervals[index] : undefined;
    };

    /**
     * Finds and returns the data for the interval that contains the specified date.
     *
     * @param {JulianDate} date The date to search for.
     * @returns {Object} The data for the interval containing the specified date, or <code>undefined</code> if no such interval exists.
     */
    TimeIntervalCollection.prototype.findDataForIntervalContainingDate = function(date) {
        var index = this.indexOf(date);
        return index >= 0 ? this._intervals[index].data : undefined;
    };

    /**
     * Checks if the specified date is inside this collection.
     *
     * @param {JulianDate} julianDate The date to check.
     * @returns {Boolean} <code>true</code> if the collection contains the specified date, <code>false</code> otherwise.
     */
    TimeIntervalCollection.prototype.contains = function(date) {
        return this.indexOf(date) >= 0;
    };

    var indexOfScratch = new TimeInterval();

    /**
     * Finds and returns the index of the interval in the collection that contains the specified date.
     *
     * @param {JulianDate} date The date to search for.
     * @returns {Number} The index of the interval that contains the specified date, if no such interval exists,
     * it returns a negative number which is the bitwise complement of the index of the next interval that
     * starts after the date, or if no interval starts after the specified date, the bitwise complement of
     * the length of the collection.
     */
    TimeIntervalCollection.prototype.indexOf = function(date) {
        //>>includeStart('debug', pragmas.debug);
        if (!defined(date)) {
            throw new DeveloperError('date is required');
        }
        //>>includeEnd('debug');

        var intervals = this._intervals;
        indexOfScratch.start = date;
        indexOfScratch.stop = date;
        var index = binarySearch(intervals, indexOfScratch, compareIntervalStartTimes);
        if (index >= 0) {
            if (intervals[index].isStartIncluded) {
                return index;
            }

            if (index > 0 && intervals[index - 1].stop.equals(date) && intervals[index - 1].isStopIncluded) {
                return index - 1;
            }
            return ~index;
        }

        index = ~index;
        if (index > 0 && (index - 1) < intervals.length && TimeInterval.contains(intervals[index - 1], date)) {
            return index - 1;
        }
        return ~index;
    };

    /**
     * Returns the first interval in the collection that matches the specified parameters.
     * All parameters are optional and <code>undefined</code> parameters are treated as a don't care condition.
     *
     * @param {Object} [options] Object with the following properties:
     * @param {JulianDate} [options.start] The start time of the interval.
     * @param {JulianDate} [options.stop] The stop time of the interval.
     * @param {Boolean} [options.isStartIncluded] <code>true</code> if <code>options.start</code> is included in the interval, <code>false</code> otherwise.
     * @param {Boolean} [options.isStopIncluded] <code>true</code> if <code>options.stop</code> is included in the interval, <code>false</code> otherwise.
     * @returns {TimeInterval} The first interval in the collection that matches the specified parameters.
     */
    TimeIntervalCollection.prototype.findInterval = function(options) {
        options = defaultValue(options, defaultValue.EMPTY_OBJECT);
        var start = options.start;
        var stop = options.stop;
        var isStartIncluded = options.isStartIncluded;
        var isStopIncluded = options.isStopIncluded;

        var intervals = this._intervals;
        for (var i = 0, len = intervals.length; i < len; i++) {
            var interval = intervals[i];
            if ((!defined(start) || interval.start.equals(start)) &&
                (!defined(stop) || interval.stop.equals(stop)) &&
                (!defined(isStartIncluded) || interval.isStartIncluded === isStartIncluded) &&
                (!defined(isStopIncluded) || interval.isStopIncluded === isStopIncluded)) {
                return intervals[i];
            }
        }
        return undefined;
    };

    /**
     * Adds an interval to the collection, merging intervals that contain the same data and
     * splitting intervals of different data as needed in order to maintain a non-overlapping collection.
     * The data in the new interval takes precedence over any existing intervals in the collection.
     *
     * @param {TimeInterval} interval The interval to add.
     * @param {TimeInterval~DataComparer} [dataComparer] A function which compares the data of the two intervals.  If omitted, reference equality is used.
     */
    TimeIntervalCollection.prototype.addInterval = function(interval, dataComparer) {
        //>>includeStart('debug', pragmas.debug);
        if (!defined(interval)) {
            throw new DeveloperError("interval is required");
        }
        //>>includeEnd('debug');

        if (interval.isEmpty) {
            return;
        }

        var comparison;
        var index;
        var intervals = this._intervals;

        // Handle the common case quickly: we're adding a new interval which is after all existing intervals.
        if (intervals.length === 0 || JulianDate.greaterThan(interval.start, intervals[intervals.length - 1].stop)) {
            intervals.push(interval);
            this._changedEvent.raiseEvent(this);
            return;
        }

        // Keep the list sorted by the start date
        index = binarySearch(intervals, interval, compareIntervalStartTimes);
        if (index < 0) {
            index = ~index;
        } else {
            // interval's start date exactly equals the start date of at least one interval in the collection.
            // It could actually equal the start date of two intervals if one of them does not actually
            // include the date.  In that case, the binary search could have found either.  We need to
            // look at the surrounding intervals and their IsStartIncluded properties in order to make sure
            // we're working with the correct interval.
            if (index > 0 && interval.isStartIncluded && intervals[index - 1].isStartIncluded && intervals[index - 1].start.equals(interval.start)) {
                --index;
            } else if (index < intervals.length && !interval.isStartIncluded && intervals[index].isStartIncluded && intervals[index].start.equals(interval.start)) {
                ++index;
            }
        }

        if (index > 0) {
            // Not the first thing in the list, so see if the interval before this one
            // overlaps this one.
            comparison = JulianDate.compare(intervals[index - 1].stop, interval.start);
            if (comparison > 0 || (comparison === 0 && (intervals[index - 1].isStopIncluded || interval.isStartIncluded))) {
                // There is an overlap
                if (defined(dataComparer) ? dataComparer(intervals[index - 1].data, interval.data) : (intervals[index - 1].data === interval.data)) {
                    // Overlapping intervals have the same data, so combine them
                    if (JulianDate.greaterThan(interval.stop, intervals[index - 1].stop)) {
                        interval = new TimeInterval({
                            start : intervals[index - 1].start,
                            stop : interval.stop,
                            isStartIncluded : intervals[index - 1].isStartIncluded,
                            isStopIncluded : interval.isStopIncluded,
                            data : interval.data
                        });
                    } else {
                        interval = new TimeInterval({
                            start : intervals[index - 1].start,
                            stop : intervals[index - 1].stop,
                            isStartIncluded : intervals[index - 1].isStartIncluded,
                            isStopIncluded : intervals[index - 1].isStopIncluded || (interval.stop.equals(intervals[index - 1].stop) && interval.isStopIncluded),
                            data : interval.data
                        });
                    }
                    intervals.splice(index - 1, 1);
                    --index;
                } else {
                    // Overlapping intervals have different data.  The new interval
                    // being added 'wins' so truncate the previous interval.
                    // If the existing interval extends past the end of the new one,
                    // split the existing interval into two intervals.
                    comparison = JulianDate.compare(intervals[index - 1].stop, interval.stop);
                    if (comparison > 0 || (comparison === 0 && intervals[index - 1].isStopIncluded && !interval.isStopIncluded)) {
                        intervals.splice(index - 1, 1, new TimeInterval({
                            start : intervals[index - 1].start,
                            stop : interval.start,
                            isStartIncluded : intervals[index - 1].isStartIncluded,
                            isStopIncluded : !interval.isStartIncluded,
                            data : intervals[index - 1].data
                        }), new TimeInterval({
                            start : interval.stop,
                            stop : intervals[index - 1].stop,
                            isStartIncluded : !interval.isStopIncluded,
                            isStopIncluded : intervals[index - 1].isStopIncluded,
                            data : intervals[index - 1].data
                        }));
                    } else {
                        intervals[index - 1] = new TimeInterval({
                            start : intervals[index - 1].start,
                            stop : interval.start,
                            isStartIncluded : intervals[index - 1].isStartIncluded,
                            isStopIncluded : !interval.isStartIncluded,
                            data : intervals[index - 1].data
                        });
                    }
                }
            }
        }

        while (index < intervals.length) {
            // Not the last thing in the list, so see if the intervals after this one overlap this one.
            comparison = JulianDate.compare(interval.stop, intervals[index].start);
            if (comparison > 0 || (comparison === 0 && (interval.isStopIncluded || intervals[index].isStartIncluded))) {
                // There is an overlap
                if (defined(dataComparer) ? dataComparer(intervals[index].data, interval.data) : intervals[index].data === interval.data) {
                    // Overlapping intervals have the same data, so combine them
                    interval = new TimeInterval({
                        start : interval.start,
                        stop : JulianDate.greaterThan(intervals[index].stop, interval.stop) ? intervals[index].stop : interval.stop,
                        isStartIncluded : interval.isStartIncluded,
                        isStopIncluded : JulianDate.greaterThan(intervals[index].stop, interval.stop) ? intervals[index].isStopIncluded : interval.isStopIncluded,
                        data : interval.data
                    });
                    intervals.splice(index, 1);
                } else {
                    // Overlapping intervals have different data.  The new interval
                    // being added 'wins' so truncate the next interval.
                    intervals[index] = new TimeInterval({
                        start : interval.stop,
                        stop : intervals[index].stop,
                        isStartIncluded : !interval.isStopIncluded,
                        isStopIncluded : intervals[index].isStopIncluded,
                        data : intervals[index].data
                    });
                    if (intervals[index].isEmpty) {
                        intervals.splice(index, 1);
                    } else {
                        // Found a partial span, so it is not possible for the next
                        // interval to be spanned at all.  Stop looking.
                        break;
                    }
                }
            } else {
                // Found the last one we're spanning, so stop looking.
                break;
            }
        }

        // Add the new interval
        intervals.splice(index, 0, interval);
        this._changedEvent.raiseEvent(this);
    };

    /**
     * Removes the specified interval from this interval collection, creating a hole over the specified interval.
     * The data property of the input interval is ignored.
     *
     * @param {TimeInterval} interval The interval to remove.
     * @returns <code>true</code> if the interval was removed, <code>false</code> if no part of the interval was in the collection.
     */
    TimeIntervalCollection.prototype.removeInterval = function(interval) {
        //>>includeStart('debug', pragmas.debug);
        if (!defined(interval)) {
            throw new DeveloperError("interval is required");
        }
        //>>includeEnd('debug');

        if (interval.isEmpty) {
            return false;
        }

        var result = false;
        var intervals = this._intervals;

        var index = binarySearch(intervals, interval, compareIntervalStartTimes);
        if (index < 0) {
            index = ~index;
        }

        var intervalStart = interval.start;
        var intervalStop = interval.stop;
        var intervalIsStartIncluded = interval.isStartIncluded;
        var intervalIsStopIncluded = interval.isStopIncluded;

        // Check for truncation of the end of the previous interval.
        if (index > 0) {
            var indexMinus1 = intervals[index - 1];
            var indexMinus1Stop = indexMinus1.stop;
            if (JulianDate.greaterThan(indexMinus1Stop, intervalStart) ||
                (TimeInterval.equals(indexMinus1Stop, intervalStart) &&
                 indexMinus1.isStopIncluded && intervalIsStartIncluded)) {
                result = true;

                if (JulianDate.greaterThan(indexMinus1Stop, intervalStop) ||
                    (indexMinus1.isStopIncluded && !intervalIsStopIncluded && TimeInterval.equals(indexMinus1Stop, intervalStop))) {
                    // Break the existing interval into two pieces
                    intervals.splice(index, 0, new TimeInterval({
                        start : intervalStop,
                        stop : indexMinus1Stop,
                        isStartIncluded : !intervalIsStopIncluded,
                        isStopIncluded : indexMinus1.isStopIncluded,
                        data : indexMinus1.data
                    }));
                }
                intervals[index - 1] = new TimeInterval({
                    start : indexMinus1.start,
                    stop : intervalStart,
                    isStartIncluded : indexMinus1.isStartIncluded,
                    isStopIncluded : !intervalIsStartIncluded,
                    data : indexMinus1.data
                });
            }
        }

        // Check if the Start of the current interval should remain because interval.start is the same but
        // it is not included.
        var indexInterval = intervals[index];
        if (index < intervals.length &&
            !intervalIsStartIncluded &&
            indexInterval.isStartIncluded &&
            intervalStart.equals(indexInterval.start)) {
            result = true;

            intervals.splice(index, 0, new TimeInterval({
                start : indexInterval.start,
                stop : indexInterval.start,
                isStartIncluded : true,
                isStopIncluded : true,
                data : indexInterval.data
            }));
            ++index;
            indexInterval = intervals[index];
        }

        // Remove any intervals that are completely overlapped by the input interval.
        while (index < intervals.length && JulianDate.greaterThan(intervalStop, indexInterval.stop)) {
            result = true;
            intervals.splice(index, 1);
            indexInterval = intervals[index];
        }

        // Check for the case where the input interval ends on the same date
        // as an existing interval.
        if (index < intervals.length && intervalStop.equals(indexInterval.stop)) {
            result = true;

            if (!intervalIsStopIncluded && indexInterval.isStopIncluded) {
                // Last point of interval should remain because the stop date is included in
                // the existing interval but is not included in the input interval.
                if ((index + 1) < intervals.length && intervals[index + 1].start.equals(intervalStop) && indexInterval.data === intervals[index + 1].data) {
                    // Combine single point with the next interval
                    intervals.splice(index, 1);
                    indexInterval = new TimeInterval({
                        start : indexInterval.start,
                        stop : indexInterval.stop,
                        isStartIncluded : true,
                        isStopIncluded : indexInterval.isStopIncluded,
                        data : indexInterval.data
                    });
                } else {
                    indexInterval = new TimeInterval({
                        start : intervalStop,
                        stop : intervalStop,
                        isStartIncluded : true,
                        isStopIncluded : true,
                        data : indexInterval.data
                    });
                }
                intervals[index] = indexInterval;
            } else {
                // Interval is completely overlapped
                intervals.splice(index, 1);
            }
        }

        // Truncate any partially-overlapped intervals.
        if (index < intervals.length &&
            (JulianDate.greaterThan(intervalStop, indexInterval.start) ||
             (intervalStop.equals(indexInterval.start) &&
              intervalIsStopIncluded &&
              indexInterval.isStartIncluded))) {
            result = true;
            intervals[index] = new TimeInterval({
                start : intervalStop,
                stop : indexInterval.stop,
                isStartIncluded : !intervalIsStopIncluded,
                isStopIncluded : indexInterval.isStopIncluded,
                data : indexInterval.data
            });
        }

        if (result) {
            this._changedEvent.raiseEvent(this);
        }

        return result;
    };

    /**
     * Creates a new instance that is the intersection of this collection and the provided collection.
     *
     * @param {TimeIntervalCollection} other The collection to intersect with.
     * @param {TimeInterval~DataComparer} [dataComparer] A function which compares the data of the two intervals.  If omitted, reference equality is used.
     * @param {TimeInterval~MergeCallback} [mergeCallback] A function which merges the data of the two intervals. If omitted, the data from the left interval will be used.
     * @returns {TimeIntervalCollection} A new TimeIntervalCollection which is the intersection of this collection and the provided collection.
     */
    TimeIntervalCollection.prototype.intersect = function(other, dataComparer, mergeCallback) {
        //>>includeStart('debug', pragmas.debug);
        if (!defined(other)) {
            throw new DeveloperError('other is required.');
        }
        //>>includeEnd('debug');

        var left = 0;
        var right = 0;
        var result = new TimeIntervalCollection();
        var intervals = this._intervals;
        var otherIntervals = other._intervals;

        while (left < intervals.length && right < otherIntervals.length) {
            var leftInterval = intervals[left];
            var rightInterval = otherIntervals[right];
            if (JulianDate.lessThan(leftInterval.stop, rightInterval.start)) {
                ++left;
            } else if (JulianDate.lessThan(rightInterval.stop, leftInterval.start)) {
                ++right;
            } else {
                // The following will return an intersection whose data is 'merged' if the callback is defined
                if (defined(mergeCallback) ||
                   ((defined(dataComparer) && dataComparer(leftInterval.data, rightInterval.data)) ||
                    (!defined(dataComparer) && rightInterval.data === leftInterval.data))) {

                    var intersection = TimeInterval.intersect(leftInterval, rightInterval, new TimeInterval(), mergeCallback);
                    if (!intersection.isEmpty) {
                        // Since we start with an empty collection for 'result', and there are no overlapping intervals in 'this' (as a rule),
                        // the 'intersection' will never overlap with a previous interval in 'result'.  So, no need to do any additional 'merging'.
                        result.addInterval(intersection, dataComparer);
                    }
                }

                if (JulianDate.lessThan(leftInterval.stop, rightInterval.stop) ||
                    (leftInterval.stop.equals(rightInterval.stop) &&
                     !leftInterval.isStopIncluded &&
                     rightInterval.isStopIncluded)) {
                    ++left;
                } else {
                    ++right;
                }
            }
        }
        return result;
    };

    return TimeIntervalCollection;
});