nemesiscodex/openfonacide

View on GitHub
static/js/dncp/util/smartLogSteps.js

Summary

Maintainability
C
7 hrs
Test Coverage
/**
 * Echarts, logarithmic axis reform
 *
 * @author sushuang (sushuang@baidu.com),
 *         Ievgenii (@Ievgeny, ievgeny@zoomdata.com)
 */

define(function(require) {

    // Reference
    var number = require('./number');
    var Mt = Math;
    var mathLog = Mt.log;
    var mathPow = Mt.pow;
    var mathAbs = Mt.abs;
    var mathCeil = Mt.ceil;
    var mathFloor = Mt.floor;

    // Constant
    var LOG_BASE = Mt.E; // It is not necessary to specify log base,
                         // because log(logBase, x) = ln(x) / ln(logBase),
                         // thus final result (axis tick location) is only determined by ln(x).
    var LN10 = Mt.LN10;
    var LN2 = Mt.LN2;
    var LN2D10 = LN2 / LN10;
    var EPSILON = 1e-9;
    var DEFAULT_SPLIT_NUMBER = 5;
    var MIN_BASE_10_SPLIT_NUMBER = 2;
    var SUPERSCRIPTS = {
        '0': '⁰',
        '1': '¹',
        '2': '²',
        '3': '³',
        '4': '⁴',
        '5': '⁵',
        '6': '⁶',
        '7': '⁷',
        '8': '⁸',
        '9': '⁹',
        '-': '⁻'
    };

    // Static variable
    var logPositive;
    var logLabelBase;
    var logLabelMode; // enumeration:
                      // 'plain' (i.e. axis labels are shown like 10000)
                      // 'exponent' (i.e. axis labels are shown like 10²)
    var lnBase;
    var custOpts;
    var splitNumber;
    var logMappingOffset;
    var absMin;
    var absMax;
    var tickList;

    /**
     * Test cases:
     * [2, 4, 8, 16, 32, 64, 128]
     * [0.01, 0.1, 10, 100, 1000] logLabelBase: 3
     * [0.01, 0.1, 10, 100, 1000] logLabelBase: -12
     * [-2, -4, -8, -16, -32, -64, -128] logLabelBase: 3
     * [2, 4, 8, 16, '-', 64, 128]
     * [2, 4, 8, 16, 32, 64]
     * [2, 4, 8, 16, 32]
     * [0.00000256, 0.0016, 0.04, 0.2]
     * [0.1, 1, 10, 100, 1000, 10000, 100000, 1000000] splitNumber: 3
     * [1331, 3434, 500, 1, 1212, 4]
     * [0.14, 2, 45, 1001, 200, 0.33, 10001]
     * [0.00001, 0.00005]
     * [0.00001, 0.00005] boundaryGap: [0.2, 0.4]
     * [0.001, 2, -45, 1001, 200, 0.33, 10000]
     * [0.00000001, 0.00000012]
     * [0.000000000000001]
     * [0.00000001, 0.00000001]
     * [3, 3]
     * [12, -3, 47, 19]
     * [12, -3, 47, 19] logPositive: false
     * [-2, -4, -8, -16, -32, -64, -128]
     * [-2, -4, -8, -16, -32, -64]
     * [2, 4, 8, 16, 32] boundaryGap: [0.2, 0.4]
     * []
     * [0]
     * [10, 10, 10]
     * [0.00003, 0.00003, 0.00003]
     * [0.00001, 0.00001, 0.00001]
     * [-0.00001, -0.00001, -0.00001]
     * ['-', '-']
     * ['-', 10]
     * logarithmic axis in scatter (try dataZoom)
     * logarithmic axis width dataZoom component (try xAxis and yAxis)
     */

    /**
     * Main function. Return data object with values for axis building.
     *
     * @public
     * @param {Object} [opts] Configurable options
     * @param {number} opts.dataMin data Minimum
     * @param {number} opts.dataMax data Maximum
     * @param {number=} opts.logPositive Logarithmic sign. If not specified, it will be auto-detected.
     * @param {number=} opts.logLabelBase Logaithmic base in axis label.
     *                                    If not specified, it will be set to 10 (and use 2 for detail)
     * @param {number=} opts.splitNumber Number of sections perfered.
     * @return {Object} {
     *                      dataMin: New min,
     *                      dataMax: New max,
     *                      tickList: [Array of tick data]
     *                      logPositive: Type of data sign
     *                      dataMappingMethods: [Set of logarithmic methods]
     *                  }
     */
    function smartLogSteps(opts) {
        clearStaticVariables();
        custOpts = opts || {};

        reformSetting();
        makeTicksList();

        return [
            makeResult(),
            clearStaticVariables()
        ][0];
    }

    /**
     * All of static variables must be clear here.
     */
    function clearStaticVariables() {
        logPositive = custOpts = logMappingOffset = lnBase =
        absMin = absMax = splitNumber = tickList = logLabelBase = logLabelMode = null;
    }

    /**
     * Determine sign (logPositive, negative) of data set, if not specified.
     * Reform min and max of data.
     */
    function reformSetting() {
        // Settings of log label base
        logLabelBase = custOpts.logLabelBase;
        if (logLabelBase == null) {
            logLabelMode = 'plain';
            logLabelBase = 10;
            lnBase = LN10;
        }
        else {
            logLabelBase = +logLabelBase;
            if (logLabelBase < 1) { // log base less than 1 is not supported.
                logLabelBase = 10;
            }
            logLabelMode = 'exponent';
            lnBase = mathLog(logLabelBase);
        }

        // Settings of split number
        splitNumber = custOpts.splitNumber;
        splitNumber == null && (splitNumber = DEFAULT_SPLIT_NUMBER);

        // Setting of data min and max
        var dataMin = parseFloat(custOpts.dataMin);
        var dataMax = parseFloat(custOpts.dataMax);

        if (!isFinite(dataMin) && !isFinite(dataMax)) {
            dataMin = dataMax = 1;
        }
        else if (!isFinite(dataMin)) {
            dataMin = dataMax;
        }
        else if (!isFinite(dataMax)) {
            dataMax = dataMin;
        }
        else if (dataMin > dataMax) {
            dataMax = [dataMin, dataMin = dataMax][0]; // Exchange min, max.
        }

        // Settings of log positive
        logPositive = custOpts.logPositive;
        // If not specified, determine sign by data.
        if (logPositive == null) {
            // LogPositive is false when dataMax <= 0 && dataMin < 0.
            // LogPositive is true when dataMin >= 0.
            // LogPositive is true when dataMax >= 0 && dataMin < 0 (singular points may exists)
            logPositive = dataMax > 0 || dataMin === 0;
        }

        // Settings of absMin and absMax, which must be greater than 0.
        absMin = logPositive ? dataMin : -dataMax;
        absMax = logPositive ? dataMax : -dataMin;
        // FIXME
        // If there is any data item less then zero, it is suppose to be igonred and min should be re-calculated.
        // But it is difficult to do that in current code stucture.
        // So refactor of xxAxis.js is desired.
        absMin < EPSILON && (absMin = EPSILON);
        absMax < EPSILON && (absMax = EPSILON);
    }

    /**
     * Make tick list.
     */
    function makeTicksList() {
        tickList = [];

        // Estimate max exponent and min exponent
        var maxDataLog = fixAccurate(mathLog(absMax) / lnBase);
        var minDataLog = fixAccurate(mathLog(absMin) / lnBase);
        var maxExpon = mathCeil(maxDataLog);
        var minExpon = mathFloor(minDataLog);
        var spanExpon = maxExpon - minExpon;
        var spanDataLog = maxDataLog - minDataLog;

        if (logLabelMode === 'exponent') {
            baseAnalysis();
        }
        else { // logLabelMode === 'plain', we will self-adapter
            !(
                spanExpon <= MIN_BASE_10_SPLIT_NUMBER
                && splitNumber > MIN_BASE_10_SPLIT_NUMBER
            )
                ? baseAnalysis() : detailAnalysis();
        }

        // In this situation, only draw base-10 ticks.
        // Base-10 ticks: 10^h (i.e. 0.01, 0.1, 1, 10, 100, ...)
        function baseAnalysis() {
            if (spanExpon < splitNumber) {
                splitNumber = spanExpon;
            }
            // Suppose:
            //      spanExpon > splitNumber
            //      stepExpon := floor(spanExpon / splitNumber)
            //      splitNumberFloat := spanExpon / stepExpon
            // There are tow expressions which are identically-true:
            //      splitNumberFloat - splitNumber <= 1
            //      stepExpon * ceil(splitNumberFloat) - spanExpon <= stepExpon
            // So we can calculate as follows:
            var stepExpon = mathFloor(fixAccurate(spanExpon / splitNumber));

            // Put the plot in the middle of the min, max.
            var splitNumberAdjust = mathCeil(fixAccurate(spanExpon / stepExpon));
            var spanExponAdjust = stepExpon * splitNumberAdjust;
            var halfDiff = (spanExponAdjust - spanDataLog) / 2;
            var minExponAdjust = mathFloor(fixAccurate(minDataLog - halfDiff));

            if (aroundZero(minExponAdjust - minDataLog)) {
                minExponAdjust -= 1;
            }

            // Build logMapping offset
            logMappingOffset = -minExponAdjust * lnBase;

            // Build tickList
            for (var n = minExponAdjust; n - stepExpon <= maxDataLog; n += stepExpon) {
                tickList.push(mathPow(logLabelBase, n));
            }
        }

        // In this situation, base-2|10 ticks are used to make detailed split.
        // Base-2|10 ticks: 10^h * 2^k (i.e. 0.1, 0.2, 0.4, 1, 2, 4, 10, 20, 40),
        // where k in [0, 1, 2].
        // Because LN2 * 3 < LN10 and LN2 * 4 > LN10, k should be less than 3.
        // And when k === 3, the tick is too close to that of k === 0, which looks weird.
        // So we do not use 3.
        function detailAnalysis() {
            // Find max exponent and min exponent.
            // Calculate base on 3-hexadecimal (0, 1, 2, 10, 11, 12, 20).
            var minDecimal = toDecimalFrom4Hex(minExpon, 0);
            var endDecimal = minDecimal + 2;
            while (
                minDecimal < endDecimal
                && toH(minDecimal + 1) + toK(minDecimal + 1) * LN2D10 < minDataLog
            ) {
                minDecimal++;
            }
            var maxDecimal = toDecimalFrom4Hex(maxExpon, 0);
            var endDecimal = maxDecimal - 2; // maxDecimal is greater than 4
            while (
                maxDecimal > endDecimal
                && toH(maxDecimal - 1) + toK(maxDecimal - 1) * LN2D10 > maxDataLog
            ) {
                maxDecimal--;
            }

            // Build logMapping offset
            logMappingOffset = -(toH(minDecimal) * LN10 + toK(minDecimal) * LN2);

            // Build logMapping tickList
            for (var i = minDecimal; i <= maxDecimal; i++) {
                var h = toH(i);
                var k = toK(i);
                tickList.push(mathPow(10, h) * mathPow(2, k));
            }
        }

        // Convert to decimal number from 4-hexadecimal number,
        // where h, k means: if there is a 4-hexadecimal numer 23, then h is 2, k is 3.
        // h can be any integer (notice: h can be greater than 10 or less than 0),
        // and k belongs to [0, 1, 2, 3].
        function toDecimalFrom4Hex(h, k) {
            return h * 3 + k;
        }

        function toK(decimal) {
            return decimal - toH(decimal) * 3; // Can not calculate by '%'
        }

        function toH(decimal) {
            return mathFloor(fixAccurate(decimal / 3));
        }
    }

    /**
     * Make result
     */
    function makeResult() {
        var resultTickList = [];
        for (var i = 0, len = tickList.length; i < len; i++) {
            resultTickList[i] = (logPositive ? 1 : -1) * tickList[i];
        }
        !logPositive && resultTickList.reverse();

        var dataMappingMethods = makeDataMappingMethods();
        var value2Coord = dataMappingMethods.value2Coord;

        var newDataMin = value2Coord(resultTickList[0]);
        var newDataMax = value2Coord(resultTickList[resultTickList.length - 1]);

        if (newDataMin === newDataMax) {
            newDataMin -= 1;
            newDataMax += 1;
        }

        return {
            dataMin: newDataMin,
            dataMax: newDataMax,
            tickList: resultTickList,
            logPositive: logPositive,
            labelFormatter: makeLabelFormatter(),
            dataMappingMethods: dataMappingMethods
        };
    }

    /**
     * Make axis label formatter.
     */
    function makeLabelFormatter() {
        if (logLabelMode === 'exponent') { // For label style like 3⁴.
            // Static variables should be fixed in the scope of the methods.
            var myLogLabelBase = logLabelBase;
            var myLnBase = lnBase;

            return function (value) {
                if (!isFinite(parseFloat(value))) {
                    return '';
                }
                var sign = '';
                if (value < 0) {
                    value = -value;
                    sign = '-';
                }
                return sign + myLogLabelBase + makeSuperscriptExponent(mathLog(value) / myLnBase);
            };
        }
        else {
            return function (value) { // Normal style like 0.001, 10,000,0
                if (!isFinite(parseFloat(value))) {
                    return '';
                }
                return number.addCommas(formatNumber(value));
            };
        }
    }

    /**
     * Make calculate methods.
     */
    function makeDataMappingMethods() {
        // Static variables should be fixed in the scope of the methods.
        var myLogPositive = logPositive;
        var myLogMappingOffset = logMappingOffset;

        return {
            value2Coord: function (x) {
                if (x == null || isNaN(x) || !isFinite(x)) {
                    return x;
                }
                x = parseFloat(x); // to number
                if (!isFinite(x)) {
                    x = EPSILON;
                }
                else if (myLogPositive && x < EPSILON) {
                    // FIXME
                    // It is suppose to be ignore, but not be set to EPSILON. See comments above.
                    x = EPSILON;
                }
                else if (!myLogPositive && x > -EPSILON) {
                    x = -EPSILON;
                }
                x = mathAbs(x);
                return (myLogPositive ? 1 : -1) * (mathLog(x) + myLogMappingOffset);
            },
            coord2Value: function (x) {
                if (x == null || isNaN(x) || !isFinite(x)) {
                    return x;
                }
                x = parseFloat(x); // to number
                if (!isFinite(x)) {
                    x = EPSILON;
                }
                return myLogPositive
                    ? mathPow(LOG_BASE, x - myLogMappingOffset)
                    : -mathPow(LOG_BASE, -x + myLogMappingOffset);
            }
        };
    }

    /**
     * For example, Math.log(1000) / Math.LN10 get the result of 2.9999999999999996, rather than 3.
     * This method trys to fix it.
     * (accMath.div can not fix this problem yet.)
     */
    function fixAccurate(result) {
        return +Number(+result).toFixed(14);
    }

    /**
     * Avoid show float number like '1e-9', '-1e-10', ...
     * @return {string}
     */
    function formatNumber(num) {
        return Number(num).toFixed(15).replace(/\.?0*$/, '');
    }

    /**
     * Make superscript exponent
     */
    function makeSuperscriptExponent(exponent) {
        exponent = formatNumber(Math.round(exponent)); // Do not support float superscript.
                                                       // (because I can not find superscript style of '.')
        var result = [];
        for (var i = 0, len = exponent.length; i < len; i++) {
            var cha = exponent.charAt(i);
            result.push(SUPERSCRIPTS[cha] || '');
        }
        return result.join('');
    }

    /**
     * Decide whether near zero
     */
    function aroundZero(val) {
        return val > -EPSILON && val < EPSILON;
    }

    return smartLogSteps;
});