Many times a seemingly simple bug can have a completely unexpected cause behind it. The process of troubleshooting, debugging, and giving fixes along the way can often be quite magical. I recently encountered such a problem, here to share 🙂
origin
Recently I have been maintaining an editor project for graphic design. On the editor’s canvas, images can be dragged, rotated, and cropped like this:
To keep the image visible after cropping, we need to limit the user’s drag range. For normal images, the following boundary restrictions are obviously easy to implement:
But once the image has a rotation Angle, the behavior becomes very weird:
This is clearly not the expected behavior, so how can this bug be fixed?
Bottleneck 1: ancestral code
When addressing this problem, the first thing you need to look at is the existing code implementation. Although the previous code has similar drag-and-drop restriction logic, the UI interaction it implements is different from the new version (for example, in drag-and-drop, I need to move the image, whereas in the old implementation, I need to move the cropping box), so there is no way to reuse it directly. But why reinvent your own code when you can fix it with existing code? With this very common idea in mind, my first attempt was to understand the implementation of existing code.
Don’t read don’t know, a read scared jump. For this drag and drop limit, the existing code base has been stripped of various glue codes, and 150+ lines of code are directly related to the calculation of this limit. Why is it so complicated? The implementation steps look something like this:
- Rotate to 0~90 degrees and judge whether the left, top, right and bottom edges of the picture intersect the left, top, right and bottom edges of the cutting box.
- Rotate 90~180 degrees to determine whether the top, right, bottom and left sides of the picture intersect the left, top, right and bottom of the cutting box.
- Rotate 180~270 degrees to determine whether the right, lower, left and top edges of the picture intersect the left, top, right and bottom edges of the cutting box.
- Rotate 270~360 degrees, and determine whether the lower, left, top and right sides of the picture intersect the left, top, right and bottom of the cutting box.
The implementation is indeed quite intuitive. At the same time, however, the algorithm has 4 x 4 = 16 possible branches, each with a similar but different set of trigonometric calculations. Even though it was abstracted and wrapped, the code ended up with only four functions for actual computation, but the complexity made it difficult for me to tinker with it to fit the new interaction. So I decided to spend some time thinking about how to rewrite it.
Bottleneck 2: High school math
Now that rewriting has been decided, the core algorithm can obviously be rewritten. In contrast to the very straightforward intuition above, I found another lazy intuition after watching the interaction: if you tilt the screen a little bit, the rotated situation can be reduced to the non-rotated situation. That is to say, in code implementation, it is possible to directly reuse the simple logic when there is no rotation. Doesn’t that sound a lot easier?
Light has the idea is not good, it is meaningful to realize it. How does this idea of tilting the screen translate into code? The concept of coordinate system in high school mathematics inspired me: the position of a point can be expressed differently in multiple different coordinate systems. So for the rotated picture, as long as we rotate the frame with it, it shouldn’t be too hard to calculate the drag limit in the rotated frame. The new idea can be summed up as this algorithm:
- When the image rectangle has a rotation Angle θ, we map the dx and dy offsets of the drag event to the new cartesian coordinate system at an Angle θ from the original coordinate system.
- Using the offsets dx’ and dy’ on the new coordinate system, reuse the existing code to calculate the limitations.
- Transform the limited dx’ and dy’ back to dx and dy, using the corrected offset to move the elements.
It sounds like “mapping” and “transforming” aren’t easy, and I’m not sure if this is the right algorithm. If you do it and it doesn’t work, then time has obviously been wasted. So how do you test this idea? I’ve come up with a simple way to do it: take special values.
When the rotation Angle is any Angle, the formula for the transformation needs to be derived. But if you rotate it by exactly 90 or 180 degrees, the transformation is very simple, like this:
// Inverse transform x = -y', y = -x', y = -x'Copy the code
This is obviously very easy to do by tinkering with existing code. This magically changes the drag limit for images rotated 90 degrees. This attempt gave me a lot of confidence, so I began to try to derive the transformation in the general case by writing this formula intuitively:
X '= xcosθ + ysinθ y' = xsinθ + ycosθCopy the code
And then I try to figure out from that
x = ? x' + ? y' y = ? x' + ? y'Copy the code
This equation is difficult to solve directly through high school math violence, so I try to calculate it through matrix transformation, that is, to find the inverse matrix of the following transformation matrix:
| cosine theta sine theta | | sine theta cosine theta |Copy the code
But in the case of the ready-made matrix transformation formula, the determinant of the matrix may be zero, and the inverse matrix does not exist… I feel strange to this, so the thick skin to consult is T big mathematics department to read a bo of sensitive god, sensitive god pointed out the problem at a glance: there is a value in the transformation matrix should be negative…… Sure enough, too many things have been returned after graduation.
The answer then looks like this (ignore the ugly writing that is not supported for LaTeX) :
| = | | x 'cos theta sine theta | | x | | y "| | - sine theta cosine theta. | | y | | | = | x cosine theta - sine theta | | x' | | y | | sine theta cosine theta. | | y '|Copy the code
Applying this formula to the current code gives the following effect:
Looks like we’re done! But this is not the end…
Bottleneck 3: One step away
The problem seemed to have been solved, but when I self-tested the code before merging, I noticed that the drag limit after rotation might have an inexplicably fixed offset:
It’s a big head… The algorithm seems to be correct, and the effect is correct in general and in a few special cases, but it’s pretty weird to be so wrong in a few cases. I went through both the new code and the glue code used to get the offset, and found no problem. Because of this bug, I had to put the refactoring aside for a while and prioritize some other details.
It is interesting that even when a question is put aside, the thought about it may continue silently. While musing in the bathroom, I came across an overlooked area: the “simple logical constraints” code THAT I had been reluctant to change.
We mentioned at the beginning that the drag and drop restriction without rotation is very easy to write, just like the OpenGL “clamp” function:
const clamp = (x, lower, upper) = > Math.max(lower, Math.min(x, upper))
dx = clamp(dx, minLeft, maxLeft)
dy = clamp(dy, minTop, maxTop)
Copy the code
The CLAMP itself is correct, so I always think this code is correct. But after factoring in rotation, are the sources of the left and top trustworthy? They are offsets in screen coordinates, and the code to find them is very simple, something like this:
minLeft = rect.left - rect.width
maxTop = rect.top + rect.height
// ...
Copy the code
The position of an element within the browser, relative to the top left corner of the screen. But in the transformation formula above, the position is relative to the center point of the drag box. Taking this into account, the validity of these variables is called into question. My attempt to do this is to calculate the drag limit based on the distance between the center points of the two rectangles, rather than directly using off-the-shelf offsets. Since the spacing between the centers erases the effect of the initial position on the calculation, the offset should be eliminable. The refactored code replaces the above intermediate variables with centerDeltaX and centerDeltaY. The result is as follows:
The most troublesome bug was fixed in this way. The benefits of this improvement were still available: 150+ lines of code were optimized to 10+ lines of code, and the number of branches in the code execution path was optimized from 16 to zero. The final version looks like this:
// Cache sines and cosines in higher-order computations
const rotateVector = utils.getVectorRotator(element.rotate);
const { minLeft, maxLeft, minTop, maxTop, centerDeltaX, centerDeltaY } = element.$getDragLimit();
// Transform to the rotated frame
// The variable with _ suffix is in the rotated frame of reference
const [dx_, dy_] = rotateVector(dx, dy);
// Final offset deltaX = Drag event offset dx + Distance from the center of the two rectangles to centerDeltaX
const [centerDeltaX_, centerDeltaY_] = rotateVector(centerDeltaX, centerDeltaY);
const clampedDeltaX_ = utils.clamp(centerDeltaX_ + dx_, minLeft, maxLeft);
const clampedDeltaY_ = utils.clamp(centerDeltaY_ + dy_, minTop, maxTop);
// Convert the corrected offset back to the original coordinate system
const [clampedDeltaX, clampedDeltaY] = rotateVector(clampedDeltaX_, clampedDeltaY_, true);
[dx, dy] = [clampedDeltaX - centerDeltaX, clampedDeltaY - centerDeltaY];
[left, top] = [drag.left + dx, drag.top + dy];
Copy the code
conclusion
So far, the story has finally come to an end. While this requirement is not necessarily something we might encounter on a daily basis, some of the summary feelings of the debugging process are valuable:
- Intuition is still important. For example, the intuition of the sensitive god can directly indicate that I am missing a negative sign at a critical point (thank you), while the intuition of writing engineering code may be to rely on existing tools to find a shortcut to solve the problem as much as possible
- For complex problems, it’s much better to get the code logic right than to change the amount and save the trial and error.
- When reimplementing a set of logic seems cumbersome, you can use special inputs and outputs to give a prototype implementation of the POC, which also helps to amplify the problem and provide a clean environment for repetition.
- Note the corners of the code that you might think are unremarkable, but the entire execution link is worth considering.
- Many technical questions can be answered by drilling down the road. I could have opted to shelve this optimization, but I would have missed a workout 🙂
Limited to my level, this debugging experience is somewhat tortuous. Hope to be helpful to interested students ~