DEV Community

Cover image for Using modulo to shift a value and keep it inside a range
Timothée Boucher
Timothée Boucher

Posted on • Edited on

Using modulo to shift a value and keep it inside a range

The modulo operator is fairly simple, but often underused. In particular, I find it useful when changing a value and keeping it inside a pre-determined range.

E.g., index in an array, hours in a day, degrees on a compass.


First of all, a quick definition: the modulo operator gives the remainder of a division of one number by another. In JavaScript the modulo operator is %.

The number after the operator is called modulus.

Importantly, in JavaScript the return value is signed. What does this mean? 14 % 4 is 2, and -14 % 4 is -2. Some languages keep the result in [0, modulus - 1]. This adds some complexity to the formula below.

(if you're reading this and use a different language than JavaScript, check Wikipedia for the details on your language of choice)


Ultimate formula

The context is this: you have a starting value in a given range, you need to increase or decrease the value by a certain amount, and you need the final value to loop back and stay in that range.

This is the ultimate formula that works for all these cases:

(startingValue - minimumValue + (offset % modulus) + modulus) % modulus + minimalValue
Enter fullscreen mode Exit fullscreen mode
  • startingValue is the value you start with. It's assumed to be in your desired range already.
  • minimumValue is the lowest value of your desired range. Doing startingValue - minimumValue shifts the modulo operation to a range starting at 0. We add it back at the end to shift the value back to the desired range. NB: minimumValue can be negative too!
  • offset is the amount you want to shift your starting value by. It can be negative, positive, and as small or large as you want. We use offset % modulus to make sure we shift by the smallest amount necessary. Since this can be negative (because the modulo operation is signed), we add modulus to that to make sure the result stays in range. (see below)
  • modulus is the length of your desired range.

Adding the modulus doesn't affect the result, and guarantees that adding offset % modulus will keep the number positive in the case where offset is negative.

E.g., if you're looking at 24 hours and your offset is -50, offset % modulus is -2. Removing two hours is equivalent to adding -2 + 24 hours which is 22. In other words, this ensures that we're always adding to the value. When we subtract, we sometimes can get a negative value, which leads us to the same problem and solution.

Let's put this in practice with concrete use cases!


Cycling through an array

It's very common to need to cycle through an array and loop back on the other end. E.g., you change the selected item of a dropdown and need to go back at the top once you reach the bottom.

I have seen code like this to achieve this:

const options = ['alpha', 'beta', 'gamma', 'delta']
let selectedIndex = 0

function goDown () {
  selectedIndex = selectedIndex + 1
  if (selectedIndex === options.length) {
    selectedIndex = 0
  }
}
function goUp () {
  selectedIndex = selectedIndex - 1
  if (selectedIndex === -1) {
    selectedIndex = options.length - 1
  }
}
Enter fullscreen mode Exit fullscreen mode

It works! However, using the formula above, you can combine the two functions:

function go (offset) {
  selectedIndex = (selectedIndex + offset + options.length) % options.length
}
const goDown = () => go(1)
const goUp = () => go(-1)
Enter fullscreen mode Exit fullscreen mode
  • minimumValue here is 0 because an array's index is between 0 and options.length - 1, so we don't need it.
  • We also know that direction is either 1 or -1 so we don't need (offset % modulus), and offset is enough.

Time-related modulo

Most time units loop back: there are 12 months in a year, 24 hours in a day, 60 minutes in an hour, etc.

Because time is finicky, you may want to use dedicated time functions for this. Sometimes you can just put a modulo and be on your way!

One use-case is starting from a month index, adding or subtracting a certain number of months, and wanting to know which month you end up on.

  • Your desired range is [1, 12], so minimumValue is 1.
  • modulus is 12 because there are 12 months
function shiftMonth (startingMonth, offset) {
  return (startingMonth - 1 + (offset % 12) + 12) % 12 + 1
}
Enter fullscreen mode Exit fullscreen mode

Once again, the - 1 sets your initial value back into [0, 11], then you can do your regular operation, and you add 1 again at the end to shift back the range to [1, 12].


Angles and non-integer values

And this works with non-integer values!

For example, say you have to keep track of a direction in radians, but want to keep the value between and π.

  • minimumValue is -Math.PI
  • modulus is the length of the range: 2 * Math.PI

You can then have the following function:

function shiftAngles (startingAngle, offset) {
  return (startingAngle + Math.PI + (offset % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI) - Math.PI
}
Enter fullscreen mode Exit fullscreen mode

For contrast, this function keeps the angle between 0 and :

function shiftAnglesPositive (startingAngle, offset) {
  return (startingAngle + (offset % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
}
Enter fullscreen mode Exit fullscreen mode

In action:

> shiftAngles(Math.PI / 3, -5 * Math.PI) / Math.PI
-0.6666666666666667
> shiftAnglesPositive(Math.PI / 3, -5 * Math.PI) / Math.PI
1.3333333333333333
Enter fullscreen mode Exit fullscreen mode

I'll be honest, it's a bit of a mouthful of a formula, and it can look too clever for its own good. But it has the benefit of just working without missing edge cases, especially when the offset is unknown. If you don't use it, you end up with a bunch of ifs and it's quite easy to slip up.


Photo by Joel Fulgencio on Unsplash

Top comments (1)

Collapse
 
ats1999 profile image
Info Comment hidden by post author - thread only accessible via permalink
Rahul kumar

I have built a tool for content creators to generate open graph images for social media posts.

see -> og-image-client.vercel.app

Must check it out

Some comments have been hidden by the post's author - find out more