8
\$\begingroup\$

I wrote a function that receives a number as an argument and returns a number (technically, a string) with a maximum of 3 numerals, no matter if there's a decimal separator or not, plus the adequate SI unit. Here some examples for you to understand its logic:

0.1 --> 0.1

42.300099 --> 42.3

1026 --> 1.03k

1200000 --> 1.2M

543760 --> 544k

34069000000 --> 34.1G

As you can see, it returns a maximum of 3 numerals (for instance, these are acceptable results: 1.23, 12.3 and 123, plus any unit).

The function works (as requested in any CR question), but it feels unnecessarily convoluted. Here's the code working in the snippet, with the examples above:

function formatNumber(value) {
  const units = ["", "k", "M", "B"];
  const digits = Math.max(Math.floor(Math.log10(value)), 0) + 1;
  const keep = digits % 3 === 1 ? 2 : digits % 3 === 2 ? 1 : 0;
  const places = ~~((digits - 1) / 3);
  let unit = units[places];
  let number = Math.round((10 ** keep) * value / (10 ** (places * 3))) / (10 ** keep);
  if (number === 1e3) {
    number = Math.round(number / 1e3);
    unit = units[places + 1];
  };
  return number + unit;
};

[0.1, 42.300099, 1026, 1200000, 543760, 34069000000].forEach(e => console.log(e + " --> " + formatNumber(e)))

This is for financial data, so I have B for billions instead of the proper SI Giga. Also, since it deals with money, the code right now has no protection for values over 1 trillion (it will return number + undefined which is NaN), negative numbers and other cases, but I'm aware of that.

Here is the detailed explanation of what I tried to do:

function formatNumber(value) {

  //the SI units, changing G for B
  const units = ["", "k", "M", "B"];

  //this gets how many numerals the number has, I found it in this 
  //SO answer: https://stackoverflow.com/a/28203456/5768908
  const digits = Math.max(Math.floor(Math.log10(value)), 0) + 1;

  //based on 'digits', this value will be used to determine how many numerals there will
  //be after the decimal point. The remainder is used to calculate it: if we have
  //just 1 numeral inside that SI unit, there's space for 2 more numerals; if we have
  //2, there's space for just 1 more, and if we have 3 there's no space 
  const keep = digits % 3 === 1 ? 2 : digits % 3 === 2 ? 1 : 0;

  //based on 'digits', we get the corresponding SI unit
  const places = ~~((digits - 1) / 3);
  let unit = units[places];

  //using 'keep', this sets by how many powers of ten we'll multiply the number, round it
  //and divide again by such power of ten. That's the same logic of keeping just two
  //decimal places in a long decimal number, when we multiply it by 100, round the result
  //and then divide it by ten again
  let number = Math.round((10 ** keep) * value / (10 ** (places * 3))) / (10 ** keep);

  //because of the rounding up, some cases like 999999 will return 1000k instead of 1M,
  //so I wrote this 'if' condition
  if (number === 1e3) {
    number = Math.round(number / 1e3);
    unit = units[places + 1];
  };

  return number + unit;
};

I appreciate any feedback.

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

I found your logic somewhat difficult to follow, especially because the number of significant digits, 3, is hard-coded into many calculations. I came up with a similar but more explicit approach (formatting numbers should not be a performance-sensitive part of your application, I would hope!).

    const defaultUnits = new Map([[1e0, ""], [1e3, "K"], [1e6, "M"], [1e9, "G"]]);

function getUnitValue(value, units) {
    const unitValues = Array.from(units.keys()).sort((a, b) => b - a);
    const absValue = Math.abs(value);
    for (const unitValue of unitValues) {
        if (absValue >= unitValue) {
            return unitValue;
        }
    }
    return 1;
}

function checkBase(base) {
    if (base < 0) {
        throw RangeError(`Negative bases, such as ${base}, are not supported`);
    } else if (base - Math.floor(base) > 0) {
        throw RangeError(`Bases with fractional components, such as ${base}, are not supported`)
    }
}

function integerDigitCount(value, base) {
    checkBase(base);
    return Math.max(Math.ceil(Math.log(Math.abs(value)) / Math.log(base)), 1); // leading 0 is one non-decimal place
}

function formatNumber(value, numDigits, base = 10, units = defaultUnits) {
    checkBase(base);
    const initialUnitValue = getUnitValue(value, units);
    const valueToUnit = value / initialUnitValue;
    const nonDecimalPlaces = integerDigitCount(valueToUnit, base);
    const reqdDecimalPlaces = Math.round(numDigits - nonDecimalPlaces);
    const reqdDecimalMultiplier = (base ** reqdDecimalPlaces);
    const valueToUnitSigDigits = Math.round(valueToUnit * reqdDecimalMultiplier) / reqdDecimalMultiplier;
    if (Math.abs(valueToUnitSigDigits) >= base ** numDigits) {
        return formatNumber(valueToUnitSigDigits * initialUnitValue, numDigits, base, units); // recurse if not fully formatted
    }
    const valueString = valueToUnitSigDigits.toString(base);
    return valueString + units.get(initialUnitValue);
}

const sampleTestCases = [0.0, 0.1, 0.434245, 235.5, 0.00001, 42.300099, 1026, 1200000, 543760, 34069000000, 999999, 99999, 999.999, 999.9999999];
function runTests(testCases = sampleTestCases, numDigits = 3) {
    // generate negative testcases as well
    const allTestCases = testCases.concat(testCases.map(testCase => -testCase));
    const results = allTestCases.map(testCase => [testCase, formatNumber(testCase, numDigits)]);
    const numDigitsResult = results.map(result => [result[0], result[1], result[1].replace(/[^\d]/g, '').length]);
    const numDigitsTest = numDigitsResult.map(result => [result[0], result[1], result[2], result[2] <= numDigits]);
    numDigitsTest.forEach(result => console.log(`${result[0]} ---> ${result[1]}; |${result[1]}| = ${result[2]}; ${result[2]} <= ${numDigits} is ${result[3]}`));
    const assertion = numDigitsTest.reduce((acc, x) => (x[3] && acc), true);
    console.assert(assertion);
    return assertion;
}

runTests();

Overall, I feel this is a more straightforward approach, where the main idea is:

  • we find the largest unit which is less than the provided value,
  • express the value in terms of that unit (a quick division by the value of the unit),
  • then round off to the required number of significant digits
    • unfortunately involving a calculation of how many decimal places that entails
  • if the number of the digits of the number we end up with exceeds the number of significant digits allowed:
    • multiply the rounded-off number in units with the unit value to get back the unitless rounded-off value,
    • run that value through the function again
  • with all that done, take the rounded-off number in the appropriate units and concatenate the string representation of that with the appropriate unit suffix, and return that

The ES6 Map provides a nice way to group together the unit suffixes and their values, and with this algorithm you have a number of customization points - the choices of units, the base of the representation (10 for decimal, 16 for hexadecimal, etc.), and of course the number of significant digits.

Also, a bunch of Math.abs allows us to handle negative numbers in a sane way as well.

\$\endgroup\$
5
  • \$\begingroup\$ Thanks. The number of digits (3) is hardcoded because that's what I need the function to do, maybe a name like function formatNumberWith3Numerals would make that more clear. But it was nice passing that as an argument as well. \$\endgroup\$ Mar 2, 2022 at 8:22
  • \$\begingroup\$ Try with values like 0.0 and values just under a power of 1000 like 999.99, 999.99999. Still work? I don't use javascript. This is from a FP standpoint. \$\endgroup\$ Mar 23, 2022 at 18:27
  • \$\begingroup\$ @chux-ReinstateMonica 0.0 gives 0 and the 999.99... ones give 1K. I think that's the expected behavior? \$\endgroup\$ Mar 24, 2022 at 19:43
  • \$\begingroup\$ I know see code's handling of edge cases with Math.max(..., 1) and if (valueToUnitSigDigits >= base ** sigDigits) .... Good! \$\endgroup\$ Mar 25, 2022 at 11:37
  • \$\begingroup\$ @chux-ReinstateMonica for some reason I decided to look at how well the code handled negative numbers, and turns out, it didn't really. Updated with a bunch of Math.abs calls to enable negative number support. \$\endgroup\$ Mar 27, 2022 at 13:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.