Beej's Bit Bucket

 ⚡ Tech and Programming Fun

Transforming Numbers

2015-11-05

Math time! Target audience: beginners! If you find this piece too simplistic, please don't be insulted, but I have to warn you the most you'll get is amusement, if that. If you're very lucky, a certain video game theme might get stuck in your head.

Other folks might read this, get to the punchline at the bottom, and ask, "Why didn't you just say that to begin with?" The point here is to explore and get your hands dirty messing around with these numbers. It's a good way to get a better feel for them.

And at the end of the day, we'll hopefully have taken something that looked rather complicated to being with, and implemented it in a simple, easy-to-follow way.

What we're going to be doing is writing some code that spins a faux-3D carousel in space. And we're going to do this by thinking about the carousel elements in several very simple ways. And then we're going to come up with functions that map between those simple ways.

Because I used the word "simple" so many times, it's bound to rub off on the actual implementation. That's what I'm betting.

Here's what we want:

Now how do we get it?

Mapping!

This article is mainly talking about converting a number in one range into a number in another range using basic arithmetic. We'll use multiplication and division to scale, and addition and subtraction to shift. Here are two examples:

Two separate examples: mapping [0,1] (all numbers in the interval 0-to-1, inclusive) to [0,300], and [-1,1] to [10,2]. Nothing is to scale, obviously.

So there idea here, in the case of the first example, is to come up with a function f such that f(0.5) = 150, and so on.

A starting point

For the rotating carousel, do you want to start with angles and 3D points in space? No. Let's start simpler.

Imagine those elements are on a big circle that's spinning. After all, that's what it looks like, above, right?

Forget degrees. Let's say the angles in the circle start at 0.0 and go to 1.0. Yes, I'm just making up my own angle system, just like that. It's legal in 49 states.

OK, so I didn't make it up. That unit is called a turn, with 1.0 being 1.0 turns, very reasonably.

Angles measured in turns, with the angle 0.375 turns indicated in red.

That's pretty simple, right?

If we have 7 objects, we can split our circle into 7 parts. The circle has 1.0 turns in total, so the objects will be evenly-spaced every 1.0/7 or 0.142 turns. Object 0 with be at 0.0, object 1 will be at 0.142, object 2 will be at 2×0.142 or 0.284, and so on.

Let's see what it looks like if we set the y coordinate to 0 and the x coordinate to the [0,1] ("0 to 1") value we're using for the icon's position in turns. (In the screen below, the origin (0,0) is located at the center of the green axes, and positive y is down. Additionally, the icons' origins are at their centers for clarity. Units are in screen pixels, not icon pixels. The icon pixels are only fat because they're 8-bit, and I wanted to be à la mode.)

Well, that's useless, isn't it? y is always 0 pixels and x goes between 0 and 1 pixels, so everything's piled up. We're not exactly showing a carousel, here.

Mapping: scaling transforms

The x position is going from 0 to 1, which isn't much room. How can we make it go from 0 to, say, 300 instead?

At risk of being too obvious, you take the [0,1] x position, and multiply it by 300. This gives you a range of [(0×300),(1×300)], which is [0,300]:

x positionx position times 300
0.000
0.2575
0.50150
0.75225
1.00300

Now let's draw that again. We'll keep the original [0,1] values, but we'll then multiply them each by 300 before showing them, transforming them into this new space:

Well, hey, that's something! It's stopped looking completely horrible, but it's not a circle. We've mapped [0,1] to [0,300] in one dimension. Patience; we'll get to circles soon enough.

Mapping: translating transforms

But it's all packed on the left. What if we wanted to keep the 300-pixel width, but shift all the elements to the right 50 pixels?

Again, at risk of being too obvious, you multiply the value by 300 (to scale it), and then add 50 to it (to translate it over). [(0×300+50),(1×300+50)] is [50,350]:

See how far we've gotten just from having values that go from [0,1] and applying multiplication and addition?

But here we're stuck in one dimension and we won't get to our goal. It's time to ditch this approach and go freckin' two-dimensional.

Circles, a catcher-upper

We're going to be doing some trig, here, because that's a fun way to do circles. And I realize some of you might not have your trig all shiny right on top of your head.

Here's an animation from Wikimedia that shows the value of the sine and cosine functions for an angle. Basically the angle sweeps out through the circle over 360°, and you feed the angle into the sine and cosine. The result of both functions run from -1 to 1. (θ is the Greek letter theta, and is commonly used to represent angles.)

Sine and Cosine on the job

The blue line shows the x value (cosine θ) swinging from -1 to 1 over the circle, and the red line shows the y value (sine θ) doing the same, a bit out of phase. The point (x,y) falls on the circle of radius 1, itself, for a given value of θ.

The upshot is that, for any angle θ, cosine and sine will give you the x and y coordinates on the circle of radius 1.

Mapping to radians

What is the angle θ? What units? As we saw above, we could have degrees, or we could have turns. Degrees go from 0 to 360. Turns go from 0.0 to 1.0.

There's another common unit for angles called radians used in trig. Instead of going from 0 to 360, they go from 0 to 2π. (Yes, that π, 3.14159etc. This is a circle, after all.) Sine and cosine work with angles, conceptually, which can be degrees, radians, or anything, but most math libraries require that angle to be in radians.

OK, so we want our icons to appear in a circle, and we have their angular positions stored as turns in the range [0,1]. How do we map [0,1] turns to [0,2π] radians? We need radians to feed into sine and cosine to get the screen coordinates on the circle.

We do it the same way we mapped them to 0 to 300, except instead of multiplying by 300, we multiply by 2π:

TurnsRadians
0.000.00 × 2π = 0
0.250.25 × 2π = π/2
0.500.50 × 2π = π
0.750.75 × 2π = 3π/2
1.001.00 × 2π = 2π

And we can take those radians and pipe them into sine and cosine functions.

Now you can see how storing the icon angles in turns is quite convenient since it runs from [0,1]. If we want to convert a icon angle value to degrees, we simply multiply it by 360. If we want to convert it to radians, we multiply by 2π. Easy peasy.

Plotting a circle

So now we have a way to get our [0,1] changed over to radians. Let's use that to compute X and Y coordinates:

Pseudocode
// we've split the circle up into [0,1], so each angle for 7 // icons is 1/7 = 0.142. The ith icon is at angle i * 0.142: a = [ 0, 0.142, 0.284, 0.426, 0.568, 0.710, 0.852 ]; pi = 3.1415926536; // close enough for blog work // for all icons: for (i = 0; i < 7; i++) { // map from [0,1] turns to [0,2*pi] radians radians = a[i] * 2 * pi; x = cos(radians); y = sin(radians); setIconLocation(i, x, y); // put it there }

And let's see what that looks like:

Not this again! What happened? Well, remember the sine and cosine give us the coordinates on a circle of radius 1. So all those icons are on a circle of radius 1 pixel. Yes, it's small.

How can we make it a circle of radius 80 pixels? Well, remember how we mapped from [0,1] to [0,300], earlier? We just multiplied by 300? Well, let's do that again! We need to map our sine and cosine values so instead of going from -1 to 1, they go from -80 to 80!

Sure looks like we can just multiply them by 80, doesn't it?

Pseudocode, from above
x = cos(radians) * 80; y = sin(radians) * 80; setIconLocation(i, x, y); // put it there

And now we get this:

Oooo! Better! But the circle is centered on 0,0, which is the upper left. How can we move it down and right? Well, remember how we moved all the icons 50 pixels to the right earlier by adding 50 to all their values? Let's do that again, and move the circle 50 pixels down and 250 pixels right:

Pseudocode, from above
x = cos(radians) * 80 + 250; y = sin(radians) * 80 + 50; setIconLocation(i, x, y); // put it there

And now we get this:

Now we're getting somewhere!

Remember that we're still representing positions from 0 to 1 for the icons, deep down. We're just remapping them before displaying them!

Scrunching the circle

For our carousel effect, we want the circle squished in the y direction. Or, put another way, we don't want the y values scaled up as much as the x values. Let's map x values from [-1,1] to [-120,120], and dial down the y values to [-30,30]:

Pseudocode, from above
x = cos(radians) * 120 + 250; y = sin(radians) * 30 + 50;

And now we have this:

Looking quite good, but we have some overlap on the icons. The fronts and backs are confused, which you can see where the pineapple is on top of the Pooka™, and the flower is on top of the Fygar™. Let's deal with that next.

Z-Order

In computer graphics, the Z-order is the back-to-front stacking order of elements on the screen. It defines with objects appear on top of other objects. In HTML and CSS, this is controlled with the z-index CSS property, which is a non-negative integer. Items with higher z-indexes appear on top of items with lower indexes. (I've just glossed over how CSS z-index works to an almost criminal extent here; read the spec sometime when you have nothing else to live for.)

So what we want is items that are "in back" to have lower z-indexes than those that are "in front".

How do we how what's back and what's front, anyway? Turns out there's a happy coincidence here. You might notice that the higher up on the screen an icon appears, the farther "back" it should be.

What's the value for how high on the screen it is? It's the y coordinate we got out of the sine:

Pseudocode, from above
y = sin(radians) * 80 + 50;

Ugh. That's running from... let's compute... sine goes from -1 to 1, so the max value of y is 130 and the minimum is... -30. I don't like this much. I don't want to do math with 130 and -30. I want to do math with 0 and 1, or -1 and 1, because those are much easier on my tiny brain.

We know we have sine running from -1 to 1 before we multiply and add to it, so can we use that? Let's pull it out of the code with a minor refactor:

Pseudocode, from above
sineValue = sin(radians) y = sineValue * 80 + 50; zIndex = someFunction(sineValue) // sineValue from -1 to 1

That makes me a little happier. Now, can we map sineValue to some zIndex that is proper?

Positive y is down in screen coordinates, so as the angle goes up from 0 to π radians (0 to 0.5 turns), half way around the circle, the icon will appear below the center of the circle on the screen. Then as it goes from π to 2π radians (0.5 to 1.0 turns), it will appear above the center of the circle.

And below the center of the circle is generally "front" and above is generally "back"... But not entirely, because items below the center can still be in front of or behind one another. But it looks like stuff that appears lowest is in front of everything else, and stuff that appears highest is behind everything else.

So what's the rule?

OK, at the bottom of the circle, sineValue is 1. At the top of the circle, it's -1. So at the bottom, when the item is in front, and sineValue is 1, we want a high z-index. At the top, back, at -1, we want a low z-index.

Can we just do this, then?

Pseudocode, from above
sineValue = sin(radians) y = sineValue * 80 + 50; zIndex = sineValue; // sineValue from -1 to 1

No, unfortunately, because CSS's z-index is an integer, like we've said. Oh, and it's non-negative. We want it to go from, say, [0,1000], instead of [-1,1].

Well, then, let's do that! Let's add one to go from [-1,1] to [0,2]. Then let's multiply by 500 to go from [0,2] to [0,1000]:

Pseudocode, from above
sineValue = sin(radians) y = sinValue * 10 + 60; zIndex = toInteger((sineValue + 1) * 500); // map [-1,1] to [0,1000]

And this is what we get:

Excellent!

Perspective

In the running example at the top of the page, you might have noticed that the items in back are smaller than the items in front. How can we scale them?

When scaling with CSS transforms, a scale of 1 is regular size, and 0.5 is half size, and so on.

We already know from setting the z-index that our sine value holds the information about how far in back or front an icon is. So can we map that -1 to 1 into some scale that goes from 0.5 to 1.0?

Sure! We made math! We can do it, and we can do it with simple arithmetic!

At first glance, though, the exact arithmetic might not leap out at you. It's not as easy as just mapping something that goes from 0 to 1.

So let's make it do that first. We'll map [-1,1] to [0,1], and then map [0,1] to [0.5,1.0].

(At this point, I admit you might have just been able to find the answer without doing this machination. But I'm making a point which is this: sometimes it's easier to map a range to [0,1] before mapping it to a completely different range. Problems are sometimes made a lot easier by looking at them from the right mathematical perspective.)

How do we map [-1,1] to [0,1]? Let's just start trying things.

We want the -1 to change to 0, so we could add 1 to everything. That would change [-1,1] into [0,2]. That's not [0,1], but it's something. How can we change [0,2] to [0,1]? We could divide by 2, and that gives us [0,1]!

So to go from [-1,1] to [0,1], we add 1, and then divide by 2.

That's the first step.

Now how to we go to the values we want? How do we map from [0,1] to [0.5,1.0]?

Let's try the same approach. First add 0.5 to everything, which gives us [0.5,1.5]. Then we divide... grrr. That would blow our 0.5.

OK, instead let's note that the final result of [0.5,1.0] is 0.5 "wide". There's 0.5 of space between 0.5 and 1.0 (because 1.0 - 0.5 = 0.5).

But our [0,1] is 1 "wide". Let's make it 0.5 wide by multiplying it by 0.5 to get [0,0.5]. Now let's add 0.5 to it, giving us [0.5,1.0], which is what we want!

In summary:

Pseudocode
sineValue = sin(radians) // [-1,1] zeroToOne = (sineValue + 1) / 2 // map from [-1,1] to [0,1] pointFiveToOne = zeroToOne * 0.5 + 0.5 // map from [0,1] to [0.5,1.0] scaleValue = pointFiveToOne scaleIcon(i, scaleValue)

And applying that (using the CSS transform property with a scale argument), we get this, with items in the "back" appearing smaller:

Of course, there's nothing stopping you from cramming all that math into one equation, and it might even be better to do so for speedy running:

Pseudocode
sineValue = sin(radians) // [-1,1] // crammed together and simplified: scaleValue = (sineValue + 1) / 4 + 0.5 scaleIcon(i, scaleValue)

But the point here is to show how it's easier to come up with that math by thinking about it in a logical, step-by-step way, rather than by trying to give birth to it directly.

Bonus: setting opacity

Let's throw that on there. In CSS, opacity 0 is transparent, 0.5 is ghostly, and 1 is opaque.

It's just like the scale value from above. Let's just use it straight up. As the icon gets smaller, it will get more transparent:

Pseudocode
sineValue = sin(radians) // [-1,1] zeroToOne = (sineValue + 1) / 2 // map from [-1,1] to [0,1] pointFiveToOne = zeroToOne * 0.5 + 0.5 // map from [0,1] to [0.5,1.0] scaleValue = pointFiveToOne opacityValue = pointFiveToOne scaleIcon(i, scaleValue) opacityIcon(i, opacityValue)

to get this:

Slick.

Blurring

This doesn't work on IE, because IE. But it's a neat new addition to CSS. We're going to tell it how many pixels to blur the icons. 0 is no blur, 10 is pretty blurry. 100 is outta control.

We want the icons in the very front to be clear, and then to get blurrier as they go back. So 0 blur in the front, and, say 5 pixels blur in the back.

We'll take our sineValue from above, which runs from -1 in the back to 1 in the front, and map it to 5 in the back, and 0 in the front.

Same again, let's map [-1,1] to [0,1] by adding 1 then dividing by 2, just like before.

Then we'll map from [0,1] to [5,0] by doing... what? Let's subtract 1 to get from [0,1] to [-1,0]... then multiply by -1 to get to [1,0]... then multiply by 5 to get to [5,0]. Bammo.

Pseudocode
sineValue = sin(radians) // [-1,1] zeroToOne = (sineValue + 1) / 2 // map from [-1,1] to [0,1] fiveToZero = (zeroToOne - 1) * -1 * 5 // map from [0,1] to [5,0] blurValue = fiveToZero blurIcon(i, blurValue)

And now we have this:

But instead of being done, let's throw a wrench in the works. What if we wanted everything below the center of the circle to be in crisp focus, and only have them start to blur when they were in the back half of the circle?

We could special-case something with an if statement, but is there another way to do it with the power of math (math (math))...?

We've already decided that 0 is crisp and 5 is blurry. But let's think outside the box a little and say that 0 or less is crisp. So 0 is crisp, -1 is crisp, -1000 is crisp. And 1 is a little blurry. And 5 is really blurry.

Does that buy us anything? What about this: we know that the sine goes from -1 to 1, and now I'm saying we want blurring to go from 5 to -5. (I said anything negative was just like 0, remember, so -5 is no blurring.)

I like this mapping. I can do [-1,1] to [5,-5]. We just multiply by -5, and we're there!

Pseudocode
sineValue = sin(radians) // [-1,1] blurValue = sineValue * -5 // map from [-1,1] to [5,-5] blurIcon(i, blurValue)

There is a catch, though, because negative blur values aren't allowed. It has to be clamped at 0:

Pseudocode
sineValue = sin(radians) // [-1,1] blurValue = sineValue * -5 // map from [-1,1] to [5,-5] if (blurValue < 0) { blurValue = 0 } blurIcon(i, blurValue)

(I admit that's an if, but it's a lot simpler than the if you originally had in mind, right?)

And now we have this, where the front elements aren't blurry, but the back ones are:

Which is looking pretty frickin' good, if I do say so myself. Unless you're on IE, in which case it looks exactly like the previous example. Get a different browser right now.

Animation

Last, but not least, how do we animate this? How do we change the position of all the elements on the screen?

Well, remember that we have all their positions encoded in the range [0,1], which represents their angle on the circle. And you might remember an example from way back there where we decided to move all the elements 20 pixels to the right by adding 20 to them, right?

We can do a similar thing here. If we want to move the items a tenth of the way around the circle, we need to add 0.1 to their positions since the circle is 1.0 in total. So here we'll set up a loop that runs forever, adding a small fraction of a number to the elements' positions every frame of animation.

But if we have an element at 0.999 and we add 0.1 to it, that gives us 1.099. That's larger than 1 and our elements were in the range [0,1]. Is that a problem that it's out of range? We could fix it by subtracting 1 if it's greater than 1, right? Yes, but in this case, we don't have to. The sine will wrap around for us automatically (because circles never end). sin 2π is the same as sin 0, is the same as sin 600π. And since all our work is based on the values that come out of sine and cosine, we should be OK.

And so we'll take those numbers in the [0,1] range and map them in all the ways we have, above, to turn them into a squished circle with scaling and opacity and blurring...

JavaScript
/** * Position the elements on the screen * * animAngle: an angle in turns to shift all the positions */ function positionElements(animAngle) { // positions of the items in turns var positions = [ 0, 0.142, 0.284, 0.426, 0.568, 0.710, 0.852 ]; // List of the divs that contain the icons var iconDivs = document.querySelectorAll('#carousel > div'); // For all icons: for (var i = 0; i < positions.length; i++) { // Here's where we animate, this very next line of code. All // we're doing is adding an offset onto all the icon positions, // which will cause them to shift around the circle by that // amount. The animAngle is passed into this function by // runAnimation() which does the hard work of increasing the // animAngle over time, below. var animPos = positions[i] + animAngle; // Angle in turns // Map angle from [0,1] turns to [0,2*pi] radians // This uses the position shifted by the animAngle! var radians = animPos * 2 * Math.PI; // Compute the x and y position on the unit circle // Both these values will be in range [-1,1] var cosValue = Math.cos(radians); var sinValue = Math.sin(radians); // Remap sine from [-1,1] to [0,1] for later var sinZeroOne = (sinValue + 1) / 2; var x = cosValue; var y = sinValue; // Scale x from [-1,1] to [-120,120] x *= 120; // Scale y from [-1,1] to [-30,30] y *= 30; // Shift x 250 px right, and y 50 px down x += 250; y += 50; // Remap sinZeroOne to zIndex, [0,1] to [0,1000] var zIndex = parseInt(sinZeroOne * 1000); // Remap sinZeroOne to scale, [0,1] to [0.5,1.0] var scale = sinZeroOne * 0.5 + 0.5; // Just copy opacity from scale var opacity = scale; // Compute the blur value, remap [-1,1] to // [5,-5] and clamp to non-negative numbers var blur = sinValue * -5; if (blur < 0) { blur = 0; } // What follows is the JS to style and position // the icons on the screen var style = iconDiv[i].style; style.zIndex = zIndex; style.transform = style.webkitTransform = style.mozTransform = 'translateX(' + x + 'px) translateY(' + y + 'px) translateZ(0)' + 'scaleX(' + scale + ') scaleY(' + scale + ') scaleZ(1)'; style.opacity = opacity; style.filter = style.webkitFilter = style.mozFilter = 'blur(' + blur + 'px)'; } } /** * Run an animation */ function runAnimation() { var curPos = 0; var animSpeed = 0.1; // turns per second var prevTimestamp = -1; function step(timestamp) { positionElements(curPos); // Function declared above // Fake a prev timestamp if we don't have one already if (prevTimestamp < 0) { prevTimestamp = timestamp - 40; } var timeDeltaMs = timestamp - prevTimestamp; // milliseconds var timeDeltaS = timeDeltaMs / 1000; // seconds // Compute how far we have to step this frame and add it on var animStep = timeDeltaS * animSpeed; curPos += animStep; prevTimestamp = timestamp; window.requestAnimationFrame(step); } window.requestAnimationFrame(step); }

And we get this:

using nothing more than simple arithmetic and a bit of trig.

In General

Is there just a single formula that will map values in any number range to any other number range?

Nope.

Just kidding; of course there is. It might not be as fast as something hand-rolled, but it's easy to use.

JavaScript
/** * For v in range [A,B], return the corresponding value * in range [C,D]. */ function mapRange(v, A, B, C, D) { return C + (v - A) / (B - A) * (D - C); }

I'll go ahead and let you derive that on your own if you're interested.

In Summary

We used this technique for graphics in this case, but it really can be used in a variety of different things.

Maybe you want a value to ease in and out from 0 to 1 instead of moving linearly. You could map that [0,1] to [0,π] and feed it into a cosine function. Then you could take the result which would run from [1,-1] and map it back to [0,1]. Now you have a function that will take a value from [0,1] and change it from a straight line to a smoothed segment of cosine curve, also mapped to [0,1].

Maybe you have values in dollars, and you want to graph them as millions of dollars. You could map from [0,1,000,000] to [0,1]... by dividing by 1 million. OK, that's an overly simplistic case.

Perhaps you have a random number generator that returns numbers from [0,1] and you want it to return numbers from [-5,25], so you multiply by 30 and subtract 5.

Or maybe you have numbers that go from -10 to 10, and you want to map them to a parabola, so you square them.

Maybe you have an opacity value that goes from 0 to 1, and you need to load it into a byte from 0 to 255.

Or you might have a 3D vector representing a point in 3-space that you want to transform based on the camera position, so you multiply it by the camera matrix... So we might be getting ahead of ourselves here, but, conceptually, what's the difference between that and what we've been doing with the carousel?

This kind of transformation happens all over the place. They're just mathematical functions, right? To paraphrase Tuco, one number goes in, another comes out.

By looking at problems from different numerical perspectives, you can sometimes take a complex-looking problem and make it much simpler to bring to life. And sometimes, as we've seen, it's easiest to just start with 0 to 1.

License

The code attached to this article is hereby granted to the public domain. See Creative Commons CC0.

Dig Dug, Pooka, and Fygar are trademarks of Bandai Namco Entertainment. The graphics are copyrighted by the same entity. For their appearance here, I plead Fair Use.

Share me!

Comments

blog comments powered by Disqus
Blog  ⚡  Email beej@beej.us  ⚡  Home page