pretty-num
Version:
Lightweight module for formatting numbers to a human readable string
201 lines (176 loc) • 6.34 kB
JavaScript
import stripZeros from './strip-zeros.js';
import padZerosToFixed from './pad-zeros-to-fixed.js';
/**
* @enum {number}
*/
export const PRECISION_SETTING = {
REDUCE: 1,
REDUCE_SIGNIFICANT: 2,
FIXED: 3,
INCREASE: 4,
};
/**
* @enum {number}
*/
export const ROUNDING_MODE = {
UP: 1,
DOWN: 2,
CEIL: 3,
FLOOR: 4,
HALF_UP: 5,
HALF_DOWN: 6,
HALF_EVEN: 7,
};
/**
* Reduce precision, accept precision settings:
* - 'reduce', 'default' - reduce precision to specified number of decimal digits, strip unnecessary ending zeros.
* - 'reduceSignificant', 'significant' - reduce precision to specified number of significant decimal digits, strip unnecessary ending zeros.
* - 'fixed' - reduce precision to specified number of decimal digits, pad with ending zeros.
* - 'increase' - pad with ending zeros to increase precision to specified number of decimal digits.
* Don't work with exponential notation, use `from-exponential` if necessary
* @param {string|number} num
* @param {number} [precision]
* @param {object} [options]
* @param {PRECISION_SETTING} [options.precisionSetting = PRECISION_SETTING.REDUCE]
* @param {ROUNDING_MODE} [options.roundingMode = ROUNDING_MODE.HALF_EVEN]
* @return {string}
*/
export default function toPrecision(num, precision, options = {}) {
num = num.toString();
// leave exponential untouched
if (num.toLowerCase().indexOf('e') !== -1) {
return num;
}
if (!options.precisionSetting) {
options.precisionSetting = PRECISION_SETTING.REDUCE;
}
if (options.precisionSetting === PRECISION_SETTING.FIXED) {
let result = _reducePrecision(num, precision, {
precisionSetting: PRECISION_SETTING.REDUCE,
roundingMode: options.roundingMode,
});
result = stripZeros(result, true);
result = padZerosToFixed(result, precision);
return result;
} else if (options.precisionSetting === PRECISION_SETTING.INCREASE) {
const result = stripZeros(num);
return padZerosToFixed(result, precision);
} else {
return stripZeros(_reducePrecision(num, precision, options));
}
}
/**
* Reduce precision with `reduce` or `reduceSignificant` precisionSetting, can produce ending dot or zeros
* @param {string} numString
* @param {number} precision
* @param {object} [options]
* @param {PRECISION_SETTING} [options.precisionSetting = PRECISION_SETTING.REDUCE]
* @param {ROUNDING_MODE} [options.roundingMode]
* @return {string}
*/
export function _reducePrecision(numString, precision, {precisionSetting = PRECISION_SETTING.REDUCE, roundingMode = ROUNDING_MODE.HALF_UP} = {}) {
// do not proceed falsy precision, except `0`
if (!precision && precision !== 0) {
return numString;
}
precision = Number(precision);
const parts = numString.split('.');
let partWhole = parts[0];
const partDecimal = parts[1];
// nothing to reduce
if (!partDecimal) {
return numString;
}
// save negation and remove from string
let negation = false;
if (partWhole[0] === '-') {
negation = true;
partWhole = partWhole.slice(1);
}
// remove dot from string (it's easier to work with single integer value), dot position will be restored from parts length
numString = partWhole + partDecimal;
// if precision setting is `reduceSignificant` then start discount from zeros, ex. `.0000`, otherwise discount starting from dot `.`
if (precisionSetting === PRECISION_SETTING.REDUCE_SIGNIFICANT) {
const discountStartMatch = partDecimal.match(/^0*/);
if (discountStartMatch) {
precision += discountStartMatch[0].length;
}
}
// index from which to start erasing
const discountStartIndex = partWhole.length + precision;
// length of decimal part after erasing
const discountedDecimalPartLength = Math.min(partDecimal.length, precision);
// erased part
const remainder = numString.slice(discountStartIndex);
// string after erasing
numString = numString.slice(0, discountStartIndex);
// increment if needed by rounding mode
if (remainder && greaterThanFive(remainder, numString, negation, roundingMode)) {
numString = increment(numString);
}
// restore dot position from string end
if (discountedDecimalPartLength) {
const integerPartLength = numString.length - discountedDecimalPartLength;
numString = `${numString.slice(0, integerPartLength)}.${numString.slice(integerPartLength)}`;
}
// restore negation
return (negation ? '-' : '') + numString;
}
/**
*
* @param {string} part
* @param {string} pre
* @param {boolean} neg
* @param {ROUNDING_MODE} [mode=ROUNDING_MODE.HALF_EVEN]
* @return {boolean}
*/
function greaterThanFive(part, pre, neg, mode) {
if (!part || part === new Array(part.length + 1).join('0')) return false;
// #region UP, DOWN, CEILING, FLOOR
if (mode === ROUNDING_MODE.DOWN || (!neg && mode === ROUNDING_MODE.FLOOR)
|| (neg && mode === ROUNDING_MODE.CEIL)) return false;
if (mode === ROUNDING_MODE.UP || (neg && mode === ROUNDING_MODE.FLOOR)
|| (!neg && mode === ROUNDING_MODE.CEIL)) return true;
// #endregion
// case when part !== five
const five = `5${new Array(part.length).join('0')}`;
if (part > five) return true;
else if (part < five) return false;
// case when part === five
switch (mode) {
case ROUNDING_MODE.HALF_DOWN: {
return false;
}
case ROUNDING_MODE.HALF_UP: {
return true;
}
// case ROUNDING_MODE.HALF_EVEN:
default: {
return (Number.parseInt(pre.at(-1), 10) % 2 === 1);
}
}
}
/**
*
* @param {string} part
* @param {number} [value = 1]
* @return {string}
*/
function increment(part, value = 1) {
let str = '';
// traverse string backward
for (let index = part.length - 1; index >= 0; index -= 1) {
let digit = Number.parseInt(part[index], 10) + value;
if (digit === 10) {
value = 1;
digit = 0;
} else {
value = 0;
}
str += digit;
}
if (value) {
str += value;
}
return str.split('').reverse().join('');
}