Using pure math (i.e. no strings involved)
You can round a value to any arbitrary number of significant figures simply by first calculating the largest power of ten that the number contains, and then use that to calculate the precision to which the number should be rounded.
/**
* Rounds a value to a given number of significant figures
* @param value The number to round
* @param significanFigures The number of significant figures
* @returns The value rounded to the given number of significant figures
*/
const round = (value, significantFigures) => {
const exponent = Math.floor(Math.log10(value))
const nIntegers = exponent + 1
const precision = 10 ** (nIntegers - significantFigures)
return Math.round(value / precision) * precision
}
In the above code, precision
simply means the closest multiple we want to round to. For example, if we want to round to the closest multiple of 100, the precision is 100. If we want to round to the closest multiple of 0.1, i.e. to one decimal place, the precision is 0.1.
The exponent
is simply the exponent of the largest power of 10 contained in value. Continue reading below, if you're interested in knowing where this comes from.
nIntegers
is the number of integers (digits to the left of the decimal place) in the value.
Some examples
> round(173.25, 1)
200
> round(173.25, 2)
170
> round(173.25, 3)
173
> round(173.25, 4)
173.3
> round(173.25, 5)
173.25
An intuitive and educative explanation
Generally, we can round any number to a given precision, by first "moving the decimal place" in one direction (division), then rounding the number to the nearest integer, and finally moving the decimal place back to its original position (multiplication).
const rounded = Math.round(value / precision) * precision
For example, to round a value to the closest multiple of 10, the precision is set to 10 and we get
> Math.round(173.25 / 10) * 10
170
Similarly, if we want to round a value to one decimal place, i.e. find the closest multiple of 0.1, the precision is set to 0.1
> Math.round(173.25 / 0.1) * 0.1
173.3
Here the precision
simply means "the closest multiple we want to round to".
So how do we use this knowledge to round a value to any given number of significant figures then?
The problem we have to solve is to determine the precision we should round to, given the number of significant figures. Say we want to round the value 12345.67 to three significant figures. How do we determine that the precision should be 100, in this case?
The precision should be 100 because Math.round(12345.67 / 100) * 100
gives 12300, i.e. rounded to three significant figures.
It's actually really easy to solve.
Basically, what we have to do is to 1) determine how many digits there are on the left side of the decimal place and then 2) use that to determine how many steps to "move the decimal place" before rounding the number.
We start by counting the number of digits in the integer part of the number (that is, 5 digits) and subtract the number of significant figures we want to round to (3 digits). The result, 5 - 3 = 2, is the number of steps we should move the decimal place to the left (if the result was negative we would move the decimal place to the right).
In order to move the decimal place two steps to the left, we have to use the precision 10^2 = 100. In other words, the result gives us the power to which 10 should be raised in order to get the precision.
n_integer = "number of digits in the integer part of the number" = 5
n_significant = "the number of significant figures we want to round" = 3
precision = 10 ** (n_integer - n_significant)
That's it!
But, hey, wait a minute! You haven't actually showed us how to code this! Also, you said we didn't want to use strings. How do we count the number of digits in the integer part of the number without converting it to a string? Well, we don't have to use strings for that. Math comes to the rescue!
We know that a real value v can be expressed as a power of ten (using ten because we're working in the decimal system). That is, v = 10^a. If we now only take the integer part of a, let's call that a', the new value v' = 10^a' will be the largest power of 10 contained in v. The number of digits in v is the same as in v', which is a' + 1. As such, we have shown that n_integer = a' + 1, where a' = floor(log10(v)).
In code this looks like
const exponent = Math.floor(Math.log10(v)) // a'
const nIntegers = exponent + 1
And, as we had from before, the precision is
const precision = 10 ** (nInteger - nSignificant)
And, finally, the rounding
return Math.round(value / precision) * precision
Example
Say we want to round the value v = 12345.67 to 1 significant figure. For the above code to work, the precision has to be precision = 10000 = 10^(n_integers - 1).
If we wanted to round to 6 significant figures, the precision would have to be precision = 0.1 = 10^(n_integers - 6).
Generally, the precision has to be precision = 10^(n_integers - n_significant)
A fun side effect
Using this code and knowledge, you can round a number to the closest multiple of any value, not just the plain old and boring powers of 10 (i.e. {..., 1000, 100, 10, 1, 0.1, 0.01, ...}). No, with this you can, for example, round to the closest multiple of, say, 0.3.
> Math.round(4 / 0.3) * 0.3
3.9
0.3 * 13 = 3.9, which is the multiple of 0.3 closest to 4.