Source: Widgets/Animation/AnimationViewModel.js

/*global define*/
define([
        '../../Core/binarySearch',
        '../../Core/ClockRange',
        '../../Core/ClockStep',
        '../../Core/defined',
        '../../Core/defineProperties',
        '../../Core/DeveloperError',
        '../../Core/JulianDate',
        '../../ThirdParty/knockout',
        '../../ThirdParty/sprintf',
        '../createCommand',
        '../ToggleButtonViewModel'
    ], function(
        binarySearch,
        ClockRange,
        ClockStep,
        defined,
        defineProperties,
        DeveloperError,
        JulianDate,
        knockout,
        sprintf,
        createCommand,
        ToggleButtonViewModel) {
    'use strict';

    var monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
    var realtimeShuttleRingAngle = 15;
    var maxShuttleRingAngle = 105;

    function numberComparator(left, right) {
        return left - right;
    }

    function getTypicalMultiplierIndex(multiplier, shuttleRingTicks) {
        var index = binarySearch(shuttleRingTicks, multiplier, numberComparator);
        return index < 0 ? ~index : index;
    }

    function angleToMultiplier(angle, shuttleRingTicks) {
        //Use a linear scale for -1 to 1 between -15 < angle < 15 degrees
        if (Math.abs(angle) <= realtimeShuttleRingAngle) {
            return angle / realtimeShuttleRingAngle;
        }

        var minp = realtimeShuttleRingAngle;
        var maxp = maxShuttleRingAngle;
        var maxv;
        var minv = 0;
        var scale;
        if (angle > 0) {
            maxv = Math.log(shuttleRingTicks[shuttleRingTicks.length - 1]);
            scale = (maxv - minv) / (maxp - minp);
            return Math.exp(minv + scale * (angle - minp));
        }

        maxv = Math.log(-shuttleRingTicks[0]);
        scale = (maxv - minv) / (maxp - minp);
        return -Math.exp(minv + scale * (Math.abs(angle) - minp));
    }

    function multiplierToAngle(multiplier, shuttleRingTicks, clockViewModel) {
        if (clockViewModel.clockStep === ClockStep.SYSTEM_CLOCK) {
            return realtimeShuttleRingAngle;
        }

        if (Math.abs(multiplier) <= 1) {
            return multiplier * realtimeShuttleRingAngle;
        }

        var fastedMultipler = shuttleRingTicks[shuttleRingTicks.length - 1];
        if(multiplier > fastedMultipler){
            multiplier = fastedMultipler;
        } else if(multiplier < -fastedMultipler){
            multiplier = -fastedMultipler;
        }

        var minp = realtimeShuttleRingAngle;
        var maxp = maxShuttleRingAngle;
        var maxv;
        var minv = 0;
        var scale;

        if (multiplier > 0) {
            maxv = Math.log(fastedMultipler);
            scale = (maxv - minv) / (maxp - minp);
            return (Math.log(multiplier) - minv) / scale + minp;
        }

        maxv = Math.log(-shuttleRingTicks[0]);
        scale = (maxv - minv) / (maxp - minp);
        return -((Math.log(Math.abs(multiplier)) - minv) / scale + minp);
    }

    /**
     * The view model for the {@link Animation} widget.
     * @alias AnimationViewModel
     * @constructor
     *
     * @param {ClockViewModel} clockViewModel The ClockViewModel instance to use.
     *
     * @see Animation
     */
    function AnimationViewModel(clockViewModel) {
        //>>includeStart('debug', pragmas.debug);
        if (!defined(clockViewModel)) {
            throw new DeveloperError('clockViewModel is required.');
        }
        //>>includeEnd('debug');

        var that = this;
        this._clockViewModel = clockViewModel;
        this._allShuttleRingTicks = [];
        this._dateFormatter = AnimationViewModel.defaultDateFormatter;
        this._timeFormatter = AnimationViewModel.defaultTimeFormatter;

        /**
         * Gets or sets whether the shuttle ring is currently being dragged.  This property is observable.
         * @type {Boolean}
         * @default false
         */
        this.shuttleRingDragging = false;

        /**
         * Gets or sets whether dragging the shuttle ring should cause the multiplier
         * to snap to the defined tick values rather than interpolating between them.
         * This property is observable.
         * @type {Boolean}
         * @default false
         */
        this.snapToTicks = false;

        knockout.track(this, ['_allShuttleRingTicks', '_dateFormatter', '_timeFormatter', 'shuttleRingDragging', 'snapToTicks']);

        this._sortedFilteredPositiveTicks = [];

        this.setShuttleRingTicks(AnimationViewModel.defaultTicks);

        /**
         * Gets the string representation of the current time.  This property is observable.
         * @type {String}
         */
        this.timeLabel = undefined;
        knockout.defineProperty(this, 'timeLabel', function() {
            return that._timeFormatter(that._clockViewModel.currentTime, that);
        });

        /**
         * Gets the string representation of the current date.  This property is observable.
         * @type {String}
         */
        this.dateLabel = undefined;
        knockout.defineProperty(this, 'dateLabel', function() {
            return that._dateFormatter(that._clockViewModel.currentTime, that);
        });

        /**
         * Gets the string representation of the current multiplier.  This property is observable.
         * @type {String}
         */
        this.multiplierLabel = undefined;
        knockout.defineProperty(this, 'multiplierLabel', function() {
            var clockViewModel = that._clockViewModel;
            if (clockViewModel.clockStep === ClockStep.SYSTEM_CLOCK) {
                return 'Today';
            }

            var multiplier = clockViewModel.multiplier;

            //If it's a whole number, just return it.
            if (multiplier % 1 === 0) {
                return multiplier.toFixed(0) + 'x';
            }

            //Convert to decimal string and remove any trailing zeroes
            return multiplier.toFixed(3).replace(/0{0,3}$/, "") + 'x';
        });

        /**
         * Gets or sets the current shuttle ring angle.  This property is observable.
         * @type {Number}
         */
        this.shuttleRingAngle = undefined;
        knockout.defineProperty(this, 'shuttleRingAngle', {
            get : function() {
                return multiplierToAngle(clockViewModel.multiplier, that._allShuttleRingTicks, clockViewModel);
            },
            set : function(angle) {
                angle = Math.max(Math.min(angle, maxShuttleRingAngle), -maxShuttleRingAngle);
                var ticks = that._allShuttleRingTicks;

                var clockViewModel = that._clockViewModel;
                clockViewModel.clockStep = ClockStep.SYSTEM_CLOCK_MULTIPLIER;

                //If we are at the max angle, simply return the max value in either direction.
                if (Math.abs(angle) === maxShuttleRingAngle) {
                    clockViewModel.multiplier = angle > 0 ? ticks[ticks.length - 1] : ticks[0];
                    return;
                }

                var multiplier = angleToMultiplier(angle, ticks);
                if (that.snapToTicks) {
                    multiplier = ticks[getTypicalMultiplierIndex(multiplier, ticks)];
                } else {
                    if (multiplier !== 0) {
                        var positiveMultiplier = Math.abs(multiplier);

                        if (positiveMultiplier > 100) {
                            var numDigits = positiveMultiplier.toFixed(0).length - 2;
                            var divisor = Math.pow(10, numDigits);
                            multiplier = (Math.round(multiplier / divisor) * divisor) | 0;
                        } else if (positiveMultiplier > realtimeShuttleRingAngle) {
                            multiplier = Math.round(multiplier);
                        } else if (positiveMultiplier > 1) {
                            multiplier = +multiplier.toFixed(1);
                        } else if (positiveMultiplier > 0) {
                            multiplier = +multiplier.toFixed(2);
                        }
                    }
                }
                clockViewModel.multiplier = multiplier;
            }
        });

        this._canAnimate = undefined;
        knockout.defineProperty(this, '_canAnimate', function() {
            var clockViewModel = that._clockViewModel;
            var clockRange = clockViewModel.clockRange;

            if (that.shuttleRingDragging || clockRange === ClockRange.UNBOUNDED) {
                return true;
            }

            var multiplier = clockViewModel.multiplier;
            var currentTime = clockViewModel.currentTime;
            var startTime = clockViewModel.startTime;

            var result = false;
            if (clockRange === ClockRange.LOOP_STOP) {
                result = JulianDate.greaterThan(currentTime, startTime) || (currentTime.equals(startTime) && multiplier > 0);
            } else {
                var stopTime = clockViewModel.stopTime;
                result = (JulianDate.greaterThan(currentTime, startTime) && JulianDate.lessThan(currentTime, stopTime)) || //
                (currentTime.equals(startTime) && multiplier > 0) || //
                (currentTime.equals(stopTime) && multiplier < 0);
            }

            if (!result) {
                clockViewModel.shouldAnimate = false;
            }
            return result;
        });

        this._isSystemTimeAvailable = undefined;
        knockout.defineProperty(this, '_isSystemTimeAvailable', function() {
            var clockViewModel = that._clockViewModel;
            var clockRange = clockViewModel.clockRange;
            if (clockRange === ClockRange.UNBOUNDED) {
                return true;
            }

            var systemTime = clockViewModel.systemTime;
            return JulianDate.greaterThanOrEquals(systemTime, clockViewModel.startTime) && JulianDate.lessThanOrEquals(systemTime, clockViewModel.stopTime);
        });

        this._isAnimating = undefined;
        knockout.defineProperty(this, '_isAnimating', function() {
            return that._clockViewModel.shouldAnimate && (that._canAnimate || that.shuttleRingDragging);
        });

        var pauseCommand = createCommand(function() {
            var clockViewModel = that._clockViewModel;
            if (clockViewModel.shouldAnimate) {
                clockViewModel.shouldAnimate = false;
            } else if (that._canAnimate) {
                clockViewModel.shouldAnimate = true;
            }
        });

        this._pauseViewModel = new ToggleButtonViewModel(pauseCommand, {
            toggled : knockout.computed(function() {
                return !that._isAnimating;
            }),
            tooltip : 'Pause'
        });

        var playReverseCommand = createCommand(function() {
            var clockViewModel = that._clockViewModel;
            var multiplier = clockViewModel.multiplier;
            if (multiplier > 0) {
                clockViewModel.multiplier = -multiplier;
            }
            clockViewModel.shouldAnimate = true;
        });

        this._playReverseViewModel = new ToggleButtonViewModel(playReverseCommand, {
            toggled : knockout.computed(function() {
                return that._isAnimating && (clockViewModel.multiplier < 0);
            }),
            tooltip : 'Play Reverse'
        });

        var playForwardCommand = createCommand(function() {
            var clockViewModel = that._clockViewModel;
            var multiplier = clockViewModel.multiplier;
            if (multiplier < 0) {
                clockViewModel.multiplier = -multiplier;
            }
            clockViewModel.shouldAnimate = true;
        });

        this._playForwardViewModel = new ToggleButtonViewModel(playForwardCommand, {
            toggled : knockout.computed(function() {
                return that._isAnimating && clockViewModel.multiplier > 0 && clockViewModel.clockStep !== ClockStep.SYSTEM_CLOCK;
            }),
            tooltip : 'Play Forward'
        });

        var playRealtimeCommand = createCommand(function() {
            that._clockViewModel.clockStep = ClockStep.SYSTEM_CLOCK;
        }, knockout.getObservable(this, '_isSystemTimeAvailable'));

        this._playRealtimeViewModel = new ToggleButtonViewModel(playRealtimeCommand, {
            toggled : knockout.computed(function() {
                return clockViewModel.clockStep === ClockStep.SYSTEM_CLOCK;
            }),
            tooltip : knockout.computed(function() {
                return that._isSystemTimeAvailable ? 'Today (real-time)' : 'Current time not in range';
            })
        });

        this._slower = createCommand(function() {
            var clockViewModel = that._clockViewModel;
            var shuttleRingTicks = that._allShuttleRingTicks;
            var multiplier = clockViewModel.multiplier;
            var index = getTypicalMultiplierIndex(multiplier, shuttleRingTicks) - 1;
            if (index >= 0) {
                clockViewModel.multiplier = shuttleRingTicks[index];
            }
        });

        this._faster = createCommand(function() {
            var clockViewModel = that._clockViewModel;
            var shuttleRingTicks = that._allShuttleRingTicks;
            var multiplier = clockViewModel.multiplier;
            var index = getTypicalMultiplierIndex(multiplier, shuttleRingTicks) + 1;
            if (index < shuttleRingTicks.length) {
                clockViewModel.multiplier = shuttleRingTicks[index];
            }
        });
    }

    /**
     * Gets or sets the default date formatter used by new instances.
     *
     * @member
     * @type {AnimationViewModel~DateFormatter}
     */
    AnimationViewModel.defaultDateFormatter = function(date, viewModel) {
        var gregorianDate = JulianDate.toGregorianDate(date);
        return monthNames[gregorianDate.month - 1] + ' ' + gregorianDate.day + ' ' + gregorianDate.year;
    };

    /**
     * Gets or sets the default array of known clock multipliers associated with new instances of the shuttle ring.
     * @type {Number[]}
     */
    AnimationViewModel.defaultTicks = [//
    0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0,//
    15.0, 30.0, 60.0, 120.0, 300.0, 600.0, 900.0, 1800.0, 3600.0, 7200.0, 14400.0,//
    21600.0, 43200.0, 86400.0, 172800.0, 345600.0, 604800.0];

    /**
     * Gets or sets the default time formatter used by new instances.
     *
     * @member
     * @type {AnimationViewModel~TimeFormatter}
     */
    AnimationViewModel.defaultTimeFormatter = function(date, viewModel) {
        var gregorianDate = JulianDate.toGregorianDate(date);
        var millisecond = Math.round(gregorianDate.millisecond);
        if (Math.abs(viewModel._clockViewModel.multiplier) < 1) {
            return sprintf("%02d:%02d:%02d.%03d", gregorianDate.hour, gregorianDate.minute, gregorianDate.second, millisecond);
        }
        return sprintf("%02d:%02d:%02d UTC", gregorianDate.hour, gregorianDate.minute, gregorianDate.second);
    };

    /**
     * Gets a copy of the array of positive known clock multipliers to associate with the shuttle ring.
     *
     * @returns {Number[]} The array of known clock multipliers associated with the shuttle ring.
     */
    AnimationViewModel.prototype.getShuttleRingTicks = function() {
        return this._sortedFilteredPositiveTicks.slice(0);
    };

    /**
     * Sets the array of positive known clock multipliers to associate with the shuttle ring.
     * These values will have negative equivalents created for them and sets both the minimum
     * and maximum range of values for the shuttle ring as well as the values that are snapped
     * to when a single click is made.  The values need not be in order, as they will be sorted
     * automatically, and duplicate values will be removed.
     *
     * @param {Number[]} positiveTicks The list of known positive clock multipliers to associate with the shuttle ring.
     */
    AnimationViewModel.prototype.setShuttleRingTicks = function(positiveTicks) {
        //>>includeStart('debug', pragmas.debug);
        if (!defined(positiveTicks)) {
            throw new DeveloperError('positiveTicks is required.');
        }
        //>>includeEnd('debug');

        var i;
        var len;
        var tick;

        var hash = {};
        var sortedFilteredPositiveTicks = this._sortedFilteredPositiveTicks;
        sortedFilteredPositiveTicks.length = 0;
        for (i = 0, len = positiveTicks.length; i < len; ++i) {
            tick = positiveTicks[i];
            //filter duplicates
            if (!hash.hasOwnProperty(tick)) {
                hash[tick] = true;
                sortedFilteredPositiveTicks.push(tick);
            }
        }
        sortedFilteredPositiveTicks.sort(numberComparator);

        var allTicks = [];
        for (len = sortedFilteredPositiveTicks.length, i = len - 1; i >= 0; --i) {
            tick = sortedFilteredPositiveTicks[i];
            if (tick !== 0) {
                allTicks.push(-tick);
            }
        }
        Array.prototype.push.apply(allTicks, sortedFilteredPositiveTicks);

        this._allShuttleRingTicks = allTicks;
    };

    defineProperties(AnimationViewModel.prototype, {
        /**
         * Gets a command that decreases the speed of animation.
         * @memberof AnimationViewModel.prototype
         * @type {Command}
         */
        slower : {
            get : function() {
                return this._slower;
            }
        },

        /**
         * Gets a command that increases the speed of animation.
         * @memberof AnimationViewModel.prototype
         * @type {Command}
         */
        faster : {
            get : function() {
                return this._faster;
            }
        },

        /**
         * Gets the clock view model.
         * @memberof AnimationViewModel.prototype
         *
         * @type {ClockViewModel}
         */
        clockViewModel : {
            get : function() {
                return this._clockViewModel;
            }
        },

        /**
         * Gets the pause toggle button view model.
         * @memberof AnimationViewModel.prototype
         *
         * @type {ToggleButtonViewModel}
         */
        pauseViewModel : {
            get : function() {
                return this._pauseViewModel;
            }
        },

        /**
         * Gets the reverse toggle button view model.
         * @memberof AnimationViewModel.prototype
         *
         * @type {ToggleButtonViewModel}
         */
        playReverseViewModel : {
            get : function() {
                return this._playReverseViewModel;
            }
        },

        /**
         * Gets the play toggle button view model.
         * @memberof AnimationViewModel.prototype
         *
         * @type {ToggleButtonViewModel}
         */
        playForwardViewModel : {
            get : function() {
                return this._playForwardViewModel;
            }
        },

        /**
         * Gets the realtime toggle button view model.
         * @memberof AnimationViewModel.prototype
         *
         * @type {ToggleButtonViewModel}
         */
        playRealtimeViewModel : {
            get : function() {
                return this._playRealtimeViewModel;
            }
        },

        /**
         * Gets or sets the function which formats a date for display.
         * @memberof AnimationViewModel.prototype
         *
         * @type {AnimationViewModel~DateFormatter}
         * @default AnimationViewModel.defaultDateFormatter
         */
        dateFormatter : {
            //TODO:@exception {DeveloperError} dateFormatter must be a function.
            get : function() {
                return this._dateFormatter;
            },
            set : function(dateFormatter) {
                //>>includeStart('debug', pragmas.debug);
                if (typeof dateFormatter !== 'function') {
                    throw new DeveloperError('dateFormatter must be a function');
                }
                //>>includeEnd('debug');

                this._dateFormatter = dateFormatter;
            }
        },

        /**
         * Gets or sets the function which formats a time for display.
         * @memberof AnimationViewModel.prototype
         *
         * @type {AnimationViewModel~TimeFormatter}
         * @default AnimationViewModel.defaultTimeFormatter
         */
        timeFormatter : {
            //TODO:@exception {DeveloperError} timeFormatter must be a function.
            get : function() {
                return this._timeFormatter;
            },
            set : function(timeFormatter) {
                //>>includeStart('debug', pragmas.debug);
                if (typeof timeFormatter !== 'function') {
                    throw new DeveloperError('timeFormatter must be a function');
                }
                //>>includeEnd('debug');

                this._timeFormatter = timeFormatter;
            }
        }
    });

    //Currently exposed for tests.
    AnimationViewModel._maxShuttleRingAngle = maxShuttleRingAngle;
    AnimationViewModel._realtimeShuttleRingAngle = realtimeShuttleRingAngle;

    /**
     * A function that formats a date for display.
     * @callback AnimationViewModel~DateFormatter
     *
     * @param {JulianDate} date The date to be formatted
     * @param {AnimationViewModel} viewModel The AnimationViewModel instance requesting formatting.
     * @returns {String} The string representation of the calendar date portion of the provided date.
     */

    /**
     * A function that formats a time for display.
     * @callback AnimationViewModel~TimeFormatter
     *
     * @param {JulianDate} date The date to be formatted
     * @param {AnimationViewModel} viewModel The AnimationViewModel instance requesting formatting.
     * @returns {String} The string representation of the time portion of the provided date.
     */

    return AnimationViewModel;
});