﻿/*
* jQuery Calculation Plug-in
*
* Copyright (c) 2007 Dan G. Switzer, II
*
* Dual licensed under the MIT and GPL licenses:
*   http://www.opensource.org/licenses/mit-license.php
*   http://www.gnu.org/licenses/gpl.html
*
* Revision: 11
* Version: 0.4.07
*
* Revision History
* v0.4.07
* - Added trim to parseNumber to fix issue with whitespace in elements
* 
* v0.4.06
* - Added support for calc() "format" callback so that if return value
*   is null, then value is not updated
* - Added jQuery.isFunction() check for calc() callbacks
* 
* v0.4.05
* - Added support to the sum() & calc() method for automatically fixing precision
*   issues (will detect the max decimal spot in the number and fix to that
*   depth)
* 
* v0.4.04
* - Fixed bug #5420 by adding the defaults.cleanseNumber handler; you can
*   override this function to handle stripping number of extra digits
* 
* v0.4.02
* - Fixed bug where bind parameter was not being detecting if you specified
*   a string in method like sum(), avg(), etc.
* 
* v0.4a
* - Fixed bug in aggregate functions so that a string is passed to jQuery's
*   text() method (since numeric zero is interpetted as false)
* 
* v0.4
* - Added support for -$.99 values
* - Fixed regex so that decimal values without leading zeros are correctly
*   parsed
* - Removed defaults.comma setting
* - Changed secondary regex that cleans additional formatting from parsed
*   number
* 
* v0.3
* - Refactored the aggregate methods (since they all use the same core logic)
*   to use the $.extend() method
* - Added support for negative numbers in the regex)
* - Added min/max aggregate methods
* - Added defaults.onParseError and defaults.onParseClear methods to add logic for
*   parsing errors
* 
* v0.2
* - Fixed bug in sMethod in calc() (was using getValue, should have been setValue)
* - Added arguments for sum() to allow auto-binding with callbacks
* - Added arguments for avg() to allow auto-binding with callbacks
* 
* v0.1a
* - Added semi-colons after object declaration (for min protection)
* 
* v0.1
* - First public release
*
*/
(function ($) {

    // set the defaults
    var defaults = {
        // regular expression used to detect numbers, if you want to force the field to contain
        // numbers, you can add a ^ to the beginning or $ to the end of the regex to force the
        // the regex to match the entire string: /^(-|-\$)?(\d+(,\d{3})*(\.\d{1,})?|\.\d{1,})$/g
        reNumbers: /(-|-\$)?(\d+(,\d{3})*(\.\d{1,})?|\.\d{1,})/g
        // this function is used in the parseNumber() to cleanse up any found numbers
        // the function is intended to remove extra information found in a number such
        // as extra commas and dollar signs. override this function to strip European values
		, cleanseNumber: function (v) {
		    // cleanse the number one more time to remove extra data (like commas and dollar signs)
		    // use this for European numbers: v.replace(/[^0-9,\-]/g, "").replace(/,/g, ".")
		    return v.replace(/[^0-9.\-]/g, "");
		}
        // should the Field plug-in be used for getting values of :input elements?
		, useFieldPlugin: (!!$.fn.getValue)
        // a callback function to run when an parsing error occurs
		, onParseError: null
        // a callback function to run once a parsing error has cleared
		, onParseClear: null
    };

    // set default options
    $.Calculation = {
        version: "0.4.07",
        setDefaults: function (options) {
            $.extend(defaults, options);
        }
    };


    /*
    * jQuery.fn.parseNumber()
    *
    * returns Array - detects the DOM element and returns it's value. input
    *                 elements return the field value, other DOM objects
    *                 return their text node
    *
    * NOTE: Breaks the jQuery chain, since it returns a Number.
    *
    * Examples:
    * $("input[name^='price']").parseNumber();
    * > This would return an array of potential number for every match in the selector
    *
    */
    // the parseNumber() method -- break the chain
    $.fn.parseNumber = function (options) {
        var aValues = [];
        options = $.extend(options, defaults);

        this.each(
			function () {
			    var 
			    // get a pointer to the current element
					$el = $(this),
			    // determine what method to get it's value
					sMethod = ($el.is(":input") ? (defaults.useFieldPlugin ? "getValue" : "val") : "text"),
			    // parse the string and get the first number we find
					v = $.trim($el[sMethod]()).match(defaults.reNumbers, "");

			    // if the value is null, use 0
			    if (v == null) {
			        v = 0; // update value
			        // if there's a error callback, execute it
			        if (jQuery.isFunction(options.onParseError)) options.onParseError.apply($el, [sMethod]);
			        $.data($el[0], "calcParseError", true);
			        // otherwise we take the number we found and remove any commas
			    } else {
			        // clense the number one more time to remove extra data (like commas and dollar signs)
			        v = options.cleanseNumber.apply(this, [v[0]]);
			        // if there's a clear callback, execute it
			        if ($.data($el[0], "calcParseError") && jQuery.isFunction(options.onParseClear)) {
			            options.onParseClear.apply($el, [sMethod]);
			            // clear the error flag
			            $.data($el[0], "calcParseError", false);
			        }
			    }
			    aValues.push(parseFloat(v, 10));
			}
		);

        // return an array of values
        return aValues;
    };

    /*
    * jQuery.fn.calc()
    *
    * returns Number - performance a calculation and updates the field
    *
    * Examples:
    * $("input[name='price']").calc();
    * > This would return the sum of all the fields named price
    *
    */
    // the calc() method
    $.fn.calc = function (expr, vars, cbFormat, cbDone) {
        var 
        // create a pointer to the jQuery object
			$this = this
        // the value determine from the expression
			, exprValue = ""
        // track the precision to use
			, precision = 0
        // a pointer to the current jQuery element
			, $el
        // store an altered copy of the vars
			, parsedVars = {}
        // temp variable
			, tmp
        // the current method to use for updating the value
			, sMethod
        // a hash to store the local variables
			, _
        // track whether an error occured in the calculation
			, bIsError = false;

        // look for any jQuery objects and parse the results into numbers			
        for (var k in vars) {
            // replace the keys in the expression
            expr = expr.replace((new RegExp("(" + k + ")", "g")), "_.$1");
            if (!!vars[k] && !!vars[k].jquery) {
                parsedVars[k] = vars[k].parseNumber();
            } else {
                parsedVars[k] = vars[k];
            }
        }

        this.each(
			function (i, el) {
			    var p, len;
			    // get a pointer to the current element
			    $el = $(this);
			    // determine what method to get it's value
			    sMethod = ($el.is(":input") ? (defaults.useFieldPlugin ? "setValue" : "val") : "text");

			    // initialize the hash vars
			    _ = {};
			    for (var k in parsedVars) {
			        if (typeof parsedVars[k] == "number") {
			            _[k] = parsedVars[k];
			        } else if (typeof parsedVars[k] == "string") {
			            _[k] = parseFloat(parsedVars[k], 10);
			        } else if (!!parsedVars[k] && (parsedVars[k] instanceof Array)) {
			            // if the length of the array is the same as number of objects in the jQuery
			            // object we're attaching to, use the matching array value, otherwise use the
			            // value from the first array item
			            tmp = (parsedVars[k].length == $this.length) ? i : 0;
			            _[k] = parsedVars[k][tmp];
			        }

			        // if we're not a number, make it 0
			        if (isNaN(_[k])) _[k] = 0;

			        // check for decimals and check the precision
			        p = _[k].toString().match(/\.\d+$/gi);
			        len = (p) ? p[0].length - 1 : 0;

			        // track the highest level of precision
			        if (len > precision) precision = len;
			    }


			    // try the calculation
			    try {
			        exprValue = eval(expr);

			        // fix any the precision errors
			        if (precision) exprValue = Number(exprValue.toFixed(Math.max(precision, 4)));

			        // if there's a format callback, call it now
			        if (jQuery.isFunction(cbFormat)) {
			            // get return value
			            var tmp = cbFormat.apply(this, [exprValue])
			            // if we have a returned value (it's null null) use it
			            if (!!tmp) exprValue = tmp;
			        }

			        // if there's an error, capture the error output
			    } catch (e) {
			        exprValue = e;
			        bIsError = true;
			    }

			    // update the value
			    $el[sMethod](exprValue.toString());
			}
		);

        // if there's a format callback, call it now
        if (jQuery.isFunction(cbDone)) cbDone.apply(this, [this]);

        return this;
    };

    /*
    * Define all the core aggregate functions. All of the following methods
    * have the same functionality, but they perform different aggregate 
    * functions.
    * 
    * If this methods are called without any arguments, they will simple
    * perform the specified aggregate function and return the value. This
    * will break the jQuery chain. 
    * 
    * However, if you invoke the method with any arguments then a jQuery
    * object is returned, which leaves the chain intact.
    * 
    * 
    * jQuery.fn.sum()
    * returns Number - the sum of all fields
    *
    * jQuery.fn.avg()
    * returns Number - the avg of all fields
    *
    * jQuery.fn.min()
    * returns Number - the minimum value in the field
    *
    * jQuery.fn.max()
    * returns Number - the maximum value in the field
    * 
    * Examples:
    * $("input[name='price']").sum();
    * > This would return the sum of all the fields named price
    *
    * $("input[name='price1'], input[name='price2'], input[name='price3']").sum();
    * > This would return the sum of all the fields named price1, price2 or price3
    *
    * $("input[name^=sum]").sum("keyup", "#totalSum");
    * > This would update the element with the id "totalSum" with the sum of all the 
    * > fields whose name started with "sum" anytime the keyup event is triggered on
    * > those field.
    *
    * NOTE: The syntax above is valid for any of the aggregate functions
    *
    */
    $.each(["sum", "avg", "min", "max"], function (i, method) {
        $.fn[method] = function (bind, selector) {
            // if no arguments, then return the result of the aggregate function
            if (arguments.length == 0)
                return math[method](this.parseNumber());

            // if the selector is an options object, get the options
            var bSelOpt = selector && (selector.constructor == Object) && !(selector instanceof jQuery);

            // configure the options for this method
            var opt = bind && bind.constructor == Object ? bind : {
                bind: bind || "keyup"
				, selector: (!bSelOpt) ? selector : null
				, oncalc: null
            };

            // if the selector is an options object, extend	the options
            if (bSelOpt) opt = jQuery.extend(opt, selector);

            // if the selector exists, make sure it's a jQuery object
            if (!!opt.selector) opt.selector = $(opt.selector);

            var self = this
				, sMethod
				, doCalc = function () {
				    // preform the aggregate function
				    var value = math[method](self.parseNumber(opt));
				    // check to make sure we have a selector				
				    if (!!opt.selector) {
				        // determine how to set the value for the selector
				        sMethod = (opt.selector.is(":input") ? (defaults.useFieldPlugin ? "setValue" : "val") : "text");
				        // update the value
				        opt.selector[sMethod](value.toString());
				    }
				    // if there's a callback, run it now
				    if (jQuery.isFunction(opt.oncalc)) opt.oncalc.apply(self, [value, opt]);
				};

            // perform the aggregate function now, to ensure init values are updated
            doCalc();

            // bind the doCalc function to run each time a key is pressed
            return self.bind(opt.bind, doCalc);
        }
    });

    /*
    * Mathmatical functions
    */
    var math = {
        // sum an array
        sum: function (a) {
            var total = 0, precision = 0;

            // loop through the value and total them
            $.each(a, function (i, v) {
                // check for decimals and check the precision
                var p = v.toString().match(/\.\d+$/gi), len = (p) ? p[0].length - 1 : 0;
                // track the highest level of precision
                if (len > precision) precision = len;
                // we add 0 to the value to ensure we get a numberic value
                total += v;
            });

            // fix any the precision errors
            if (precision) total = Number(total.toFixed(precision));

            // return the values as a comma-delimited string
            return total;
        },
        // average an array
        avg: function (a) {
            // return the values as a comma-delimited string
            return math.sum(a) / a.length;
        },
        // lowest number in array
        min: function (a) {
            return Math.min.apply(Math, a);
        },
        // highest number in array
        max: function (a) {
            return Math.max.apply(Math, a);
        }
    };


})(jQuery);
