/**
* @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;
});