baja/obj/numberUtil.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam
 */

/*global Intl: false */

/**
 * @private
 * @module nmodule/bajaScript/rc/baja/obj/numberUtil
 */
define([ 'bajaScript/sys' ], function (
         baja) {

  'use strict';

  const ZEROS = '00000000000000000000';

  let numberCharactersCache = {};
  /**
   * Utilities for working with numbers.
   *
   * @private
   * @exports bajaScript/baja/obj/numberUtil
   */
  const exports = {};


////////////////////////////////////////////////////////////////
// Support functions
////////////////////////////////////////////////////////////////

  function isNumber(num) {
    return baja.hasType(num, 'baja:Number');
  }

  function addSign(num, str, forceSign) {
    return (forceSign && num >= 0 ? '+' : '') + str;
  }

  function neededZeros(actual, needed) {
    if (needed > 0) {
      if (needed > ZEROS.length) {
        needed = ZEROS.length;
      }
      return ZEROS.substr(0, needed.valueOf() - actual);
    }
    return '';
  }

  function clampPrecision(num) {
    return Math.max(Math.min(num, 20), 0);
  }

  function clampRadix(radix) {
    return radix < 2 || radix > 36 ? 10 : radix;
  }

  function localeNumberFormat(num, lang, precision) {
    const str = String(num);
    if (str.match(/[Ee]/)) {
      return str.replace('e', 'E');
    }

    const obj = isNumber(precision) ? {
      minimumFractionDigits: +precision,
      maximumFractionDigits: +precision
    } : {};
    return num.valueOf().toLocaleString(lang, obj);
  }

  function localeFormat(num, lang, precision) {
    if (!exports.$supportsLocales()) {
      return;
    }

    try {
      return localeNumberFormat(num, lang, precision);
    } catch (e) {
      try {
        return localeNumberFormat(num, lang, clampPrecision(precision));
      } catch (e2) {
        //user configured language not recognized by the browser
        baja.error(e2);
      }
    }
  }

  /**
   * @param {baja.Simple|Number} num - a `baja:Number` or JavaScript `Number`.
   * @param {string} lang - IETF language code to use for i18n
   * @param {Number} [precision] - The number of decimal places to show in
   * the return string. Specifying '0' will also remove the decimal.
   * @returns {String}
   *
   * @example
   * `toPrecision(1.5, 3) === '1.500';`
   *
   * `toPrecision(1.274, 1) === '1.3';`
   */
  function toPrecision(num, lang, precision) {
    if (isNumber(precision)) {
      let str;
      const chars = exports.getNumberCharacters(lang);
      try {
        str = num.valueOf().toFixed(+precision);
      } catch (e) {
        str = num.valueOf().toFixed(clampPrecision(precision));
      }
      return str.replace('.', chars.decimal);
    } else {
      return num.encodeToString();
    }
  }


  function formatNumberNoUnits(num, cx) {
    cx = cx || {};

    const encoded = num.encodeToString();
    const asNumber = num.valueOf();
    const radix = cx.radix;

    // return in the case of 'min', 'max', '+inf', '-inf', 'nan'
    if (isNaN(parseFloat(encoded))) {
      return encoded;
    } else if (radix) {
      return asNumber.toString(clampRadix(radix.valueOf()));
    }

    const forceSign = cx.forceSign;
    const precision = cx.precision;
    const showSeparators = cx.showSeparators;
    const trimTrailingZeros = cx.trimTrailingZeros;
    const zeroPad = cx.zeroPad;
    const lang = cx.languageTag || baja.getLanguage();
    const hasPrecision = isNumber(precision);
    const hasZeroPad = isNumber(zeroPad);
    let str = toPrecision(num, lang, precision);
    const defaultDecimal = exports.getNumberCharacters(lang).decimal;
    let idx;
    let decimals;
    let digits;

    if (!showSeparators && !hasZeroPad) {
      if (trimTrailingZeros) {
        str = doTrimTrailingZeros(str, defaultDecimal);
      }

      //nothing to touch before the decimal place
      return addSign(asNumber, str, forceSign);
    }

    if (!hasZeroPad) {
      //zero pad wins over separators
      str = localeFormat(num, lang, precision) || (+str).toLocaleString();
    }

    idx = str.lastIndexOf(defaultDecimal);
    digits = idx < 0 ? str.length : str.substr(0, idx).length;
    decimals = idx >= 0 ? str.substr(idx + 1).length : 0;

    if (hasPrecision && decimals < precision.valueOf()) {
      str += decimals ? '' : defaultDecimal;
      str += neededZeros(decimals, precision);
    }

    if (hasZeroPad) {
      str = neededZeros(digits, zeroPad) + str;
    }

    if (trimTrailingZeros) {
      str = doTrimTrailingZeros(str, defaultDecimal);
    }

    return addSign(asNumber, str, forceSign);
  }

  function doTrimTrailingZeros(str, decimal) {
    return str.replace(new RegExp('\\' + decimal + '\\d+$'), function (match) {
      match = match.replace(/0+$/, '');
      return match === decimal ? '' : match;
    });
  }

////////////////////////////////////////////////////////////////
// Exports
////////////////////////////////////////////////////////////////

  /**
   * Clears the cache, used only for testing.
   * @private
   */
  exports.$clearCache = function () {
    numberCharactersCache = {};
  };

  /**
   * Returns true if `Intl` is available
   * @private
   * @returns {boolean}
   */
  exports.$supportsLocales = function () {
    return !!(typeof Intl === 'object' && Intl && typeof Intl.NumberFormat === 'function');
  };

  /**
   * Calculate the grouping and decimal characters used in the given language.
   * @param {string} lang
   * @returns {{ grouping: string, decimal: string }}
   */
  exports.getNumberCharacters = function (lang) {
    const existing = numberCharactersCache[lang];

    if (existing) {
      return existing;
    }

    const num = 99999.9;
    const str = localeFormat(num, lang, 1) || num.toLocaleString();
    let match = str.match(/99([^9]?)999([^9]?)9/);
    if (!(match)) {
      const nf = new Intl.NumberFormat(lang);
      match = nf.formatToParts(num);
      const grouping = match.find((d) => d.type === "group").value;
      const decimal = match.find((d) => d.type === "decimal").value;
      return (numberCharactersCache[lang] = {
        grouping: grouping || ',',
        decimal: decimal || '.'
      });
    } else {
      return (numberCharactersCache[lang] = {
        grouping: match[1] || ',',
        decimal: match[2] || '.'
      });
    }
  };

  /**
   * Default decimal character; the "." in "1.5".
   * @returns {string}
   */
  exports.getDefaultDecimal = function () {
    return exports.getNumberCharacters(baja.getLanguage() || 'en').decimal;
  };

  /**
   * Default separator character; the "," in "1,234".
   * @returns {string}
   */
  exports.getDefaultGrouping = function () {
    return exports.getNumberCharacters(baja.getLanguage() || 'en').grouping;
  };

  /**
   * Convert the number to a display string.
   *
   * This accounts for units and unit conversion.
   *
   * @param {baja.Simple|Number} num - a `baja:Number` or JavaScript `Number`.
   *
   * @param {baja.Facets|Object} [cx] - Used to specify formatting facets. The
   * argument can also be an Object Literal.
   *
   * @param {Boolean} [cx.forceSign] - specifying 'true' will concatenate a '+'
   * to the beginning of the number if positive.
   *
   * @param {Number} [cx.precision] - The number of decimal places to show in
   * the return string. Specifying '0' will also remove the decimal.
   *
   * @param {Number} [cx.radix] - Specify the number base of the return string.
   *
   * @param {Boolean} [cx.showSeparators] - include separators.
   *
   * @param {baja.Unit} [cx.units] - the baja Unit to apply to the returned
   * String.
   *
   * @param {Boolean} [cx.showUnits] - if false, don't show the units. Units may still
   * be converted if this is set to false.
   *
   * @param {baja.Enum|Number|String} [cx.unitConversion] - the
   * `baja:UnitConversion` enum, an ordinal, or tag.
   *
   * @param {Number} [cx.zeroPad] - add leading zeros to ensure at least this
   * many digits before the decimal place.
   *
   * @returns {Promise.<String>}
   */
  exports.formatNumber = function (num, cx) {
    cx = cx || {};

    const units = cx.units;
    const unitConversion = exports.getUnitConversion(cx);
    const showUnits = cx.showUnits;

    return exports.getDisplayUnits(units, unitConversion)
      .then(function (displayUnits) {
        if (displayUnits) {

          num = num.make(units.convertTo(displayUnits, num.valueOf()));
          const display = formatNumberNoUnits(num, cx);
          const symbol = displayUnits.getSymbol();
          const prefix = displayUnits.isPrefix();
          if (showUnits !== false) {
            return prefix ? symbol + ' ' + display : display + ' ' + symbol;
          } else {
            return display;
          }
        }
        return formatNumberNoUnits(num, cx);
      });
  };

  /**
   * @param {object} [cx] a context object
   * @returns {number} `unitConversion` specified in context, or the
   * user-configured `baja.getUnitConversion()` if none is specified
   * @since Niagara 4.8
   */
  exports.getUnitConversion = function (cx) {
    if (cx && 'unitConversion' in cx) {
      return cx.unitConversion;
    }
    return baja.getUnitConversion();
  };

  /**
   * Convert a baja `Integer` or `Long` to a `String`.
   *
   * This accounts for units and unit conversion.
   *
   * @param {baja.Simple|Number} num - a `baja:Number` or JavaScript `Number`.
   *
   * @param {baja.Facets|Object} [cx] - Used to specify formatting facets. The
   * argument can also be an Object Literal.
   *
   * @param {Boolean} [cx.forceSign] - specifying 'true' will concatenate a '+'
   * to the beginning of the number if positive.
   *
   * @param {Number} [cx.radix] - Specify the number base of the return string.
   *
   * @param {Boolean} [cx.showSeparators] - include separators.
   *
   * @param {baja.Unit} [cx.units] - the baja Unit to apply to the returned
   * String.
   *
   * @param {baja.Enum|Number|String} [cx.unitConversion] - the
   * `baja:UnitConversion` enum, an ordinal, or tag.
   *
   * @param {Number} [cx.zeroPad] - add leading zeros to ensure at least this
   * many digits before the decimal place.
   *
   * @returns {Promise.<String>}
   */
  exports.integralToString = function (num, cx) {
    cx = Object.create(cx || {});
    cx.precision = 0;

    return exports.formatNumber(num, cx);
  };

  /**
   * Convert a baja `Double` or `Float` to a `String`.
   *
   * This accounts for units and unit conversion.
   *
   * @param {baja.Simple|Number} num - a `baja:Number` or JavaScript `Number`.
   *
   * @param {baja.Facets|Object} [cx] - Used to specify formatting facets. The
   * argument can also be an Object Literal.
   *
   * @param {Boolean} [cx.forceSign] - specifying 'true' will concatenate a '+'
   * to the beginning of the number if positive.
   *
   * @param {Number} [cx.precision] - The number of decimal places to show in
   * the return string. Specifying '0' will also remove the decimal. If a context
   * is provided without precision, this value will default to 2. If no context
   * is provided, there will be no precision applied.
   *
   * @param {Boolean} [cx.showSeparators] - include separators.
   *
   * @param {baja.Unit} [cx.units] - the baja Unit to apply to the returned
   * String.
   *
   * @param {baja.Enum|Number|String} [cx.unitConversion] - the `baja:UnitConversion`
   * enum, an ordinal, or tag.
   *
   * @param {Number} [cx.zeroPad] - add leading zeros to ensure at least this
   * many digits before the decimal place.
   *
   * @returns {Promise.<String>}
   */
  exports.floatingPointToString = function (num, cx) {
    const defaults = { precision: 2 };
    let cxWithDefaults;

    if (cx instanceof baja.Facets) {
      cxWithDefaults = Object.assign(defaults, cx.toObject());
    } else {
      cxWithDefaults = Object.assign(defaults, cx);
    }
    return exports.formatNumber(num, cxWithDefaults);
  };

  /**
   * Convert the given number in the specified unit, applying the given unit
   * conversion.
   *
   * @param {Number} num
   *
   * @param {baja.Unit} unit - the units the given number is considered to be in
   *
   * @param {baja.Enum|Number|String} unitConversion - the `baja:UnitConversion`
   * enum, an ordinal, or tag.
   *
   * @returns {Promise.<Number>}
   *
   * @example
   * <caption>I know I have 32 degrees Fahrenheit (English), but the user wants
   * to see metric units. What number should I show them?</caption>
   *
   * exports.convertUnitTo(32, fahrenheit, 'metric')
   *   .then(function (celsius) {
   *     expect(celsius).toBeCloseTo(0); //remember JS rounding inaccuracy
   *   });
   */
  exports.convertUnitTo = function (num, unit, unitConversion) {
    return exports.getDisplayUnits(unit, unitConversion, true)
      .then(function (displayUnits) {
        return displayUnits ?
          num.make(unit.convertTo(displayUnits, num.valueOf())) :
          num;
      });
  };

  /**
   * Convert the given number in the specified unit, removing the given unit
   * conversion.
   *
   * @param {Number} num
   *
   * @param {baja.Unit} unit - the units we want to calculate for the given
   * number
   *
   * @param {baja.Enum|Number|String} unitConversion - the `baja:UnitConversion`
   * enum, an ordinal, or tag.
   *
   * @returns {Promise.<Number>}
   *
   * @example
   * <caption>The user has the UI configured to show Celsius (metric), and has
   * typed 0. But I know the underlying point is configured for Fahrenheit. What
   * "real" number should I write to the point?</caption>
   *
   * exports.convertUnitFrom(0, fahrenheit, 'metric')
   *   .then(function (fahrenheit) {
   *     expect(fahrenheit).toBeCloseTo(32); //remember JS rounding inaccuracy
   *   });
   */
  exports.convertUnitFrom = function (num, unit, unitConversion) {
    return exports.getDisplayUnits(unit, unitConversion, true)
      .then(function (displayUnits) {
        return displayUnits ?
          num.make(displayUnits.convertTo(unit, num.valueOf())) :
          num;
      });
  };

  /**
   * Get the display unit specified by the given unit/conversion combination.
   *
   * @param {baja.Unit} [units]
   *
   * @param {baja.FrozenEnum|String|Number} [unitConversion]
   *
   * @returns {Promise} promise to be resolved with the desired display unit,
   * or `null` if no unit given.
   */
  exports.getDisplayUnits = function (units, unitConversion) {
    return baja.Unit.toDisplayUnits({ units: units, unitConversion: unitConversion });
  };

  /**
   * Return num as String with zero padding prepended if applicable.
   *
   * @param {Number|String} num
   *
   * @param {Number} n - minimum length of return String, padding with zeros if
   * necessary. No truncation will occur if length of num String is greater than
   * n.
   *
   * @returns {String}
   */
  exports.addZeroPad = function (num, n) {
    const str = num.toString(10);

    return neededZeros(str.length, n) + str;
  };

  /**
   * Returns the correct new international number format, if supported.
   * @param {String} lang the language/locale to create the format for
   * @returns {Intl.NumberFormat|undefined}
   */
  exports.getInternationalFormat = function (lang) {
    if (!exports.$supportsLocales()) {
      return;
    }

    return new Intl.NumberFormat(lang);
  };

  return exports;
});