I hate math.
When I say I hate math, I’m not saying that math is objectively bad. Mathematics have elegance and simplicity. They’re the purest expression of logical thought that I can think of. Mathematics are involved in every single technological advance since the wheel of sliced bread on fire 1. Computers, fundamentally math based machines, provide me my living. I literally eat because of math.
Still, though, I hate math. At least when it’s happening to me. It’s an activity that requires me to hold a multitude of abstract concepts, complex mappings, and relative truths in my head at once. Math is just too dense for my brain to comfortably unpack it and relate it to the real world. It’s like surgery. I’m 100% on board with mathematics when an expert doing it, but when I have to get involved, I’m just the guy standing with a knife wondering which part to cut.
That’s the real problem that I have with math. Once all the parts are labelled, and all that’s left is rearranging stuff to get the desired result, I’m golden. Regardless of my preference, I have to actually do it from time to time. 2
The most recent case was writing a Google Maps style scroll zoom for a recent contract. They have a viewport div with a much, much larger content div, and a desire to move about it.
The behaviour is easy to describe:
- it should pan when the mouse is dragged
- it should zoom the content canvas when the scrollwheel turns
- it should hold the point under the mouse cursor fixed when zooming
- it should not jump about as though you threatened it when you zoom
Panning was dead easy, just set the viewport’s
topScroll properties. For zoom, though, it just liked to break rule number four. Strategy One was to find out if anyone had already described the solution somewhere. Zilch.
Strategy Two, then: mimic what I could reverse engineer from Google’s approach, and then fix the rest. Turns out that they use CSS transforms and set the
transform-origin to the mouse location, which makes a lot of sense. At first, this worked for me too, but then fell apart once I zoomed at a different location.
Strategy Three consisted mostly of crying and fiddling desperately for a couple minutes with various parameters in the vain hope that I’d stumble upon the solution by accident. Maybe if I scale the – no, that’s worse… what about unscali – nope.
There are just so damn many points relative to other points in differently scaled units to come at it head-on. To complicate further, this is a graphical problem, making it hard to get meaningful data, and even harder to locate the failure points. I knew I needed to pan to offset the “movement” caused by the scale.
Ugh. Fine. It’s math time.
Turned into this:
So, just like in grade seven, I went to my dad. He’s an electrical engineer, much better at math than me, and has actually had to deal with a similar problem for a project of his own. Through many trials, he eventually got me to stop chasing deltas and explained that the easiest way is likely to do everything with units, like physics, and work on the unscaled version. He made the first incision and helped to label everything, which is exactly why you should make your dad be an engineer, too.
Here’s what we came up with:
First, everything we do for X can be repeated for Y, so let’s focus on one dimension.
Next, There are two units of measurement. Screen Pixels (px), and scaled virtual distances, Canvas Units (cns). The scale factor is the ratio of px/cns.
Call the point we want to zoom around Focal Point, which for us is the mouse cursor. Since everything is relative to something, lets use a second subscript to denote what it’s relative to. For example, the viewport’s origin, relative to the canvas, is Xvc.
Now, the Focus Point, relative to the canvas (Xfc), can be described with: 3
Xvc px + Xfv px Xfc cns = ------------------ S px/cns
Now to scale it. Let’s prime everything to denote after-scale values:
X'vc px + X'fv px X'fc cns = --------------------- S' px/cns
There are a couple of facts that are helpful here:
- X’fv px = Xfv px, because they are both the focus point, relative to the viewport in raw px, and so does not change.
- Further, X’fc cns = Xfc cns, since they both refer to the same logical location relative to the canvas and in canvas units.
We can sub in the equivalences and rearrange to solve for the X’vc px. This is what the viewport’s offset needs to be to compensate for the scaling.
X’vc px = ( Xvc px + Xvf px ) * (S’ px/can / S px/can) – Xfv px