Geometry done right with JS

Dávid Dobiász
Wunderman ThompsonBudapest
8 min readJan 29, 2019

I always liked mathematics back in school and university, regardless of the huge number of theories I had to study. When it was a question of having to deal with logical solutions in the code, I was always happy to take the lead. The same happened when we had to implement a new social functionality in a mobile application here in our office in Budapest.

The main idea was, that we wanted to encourage users to make suggestions for shoe design, this is why we created the co-creation section in the app.

Using co-creation, users can use their camera to take pictures and adjust them (zoom, move, rotate) to the predefined croppers, and after adjustment, they can submit the new image.

Checking the image to see if it is within the cropper (iOS)

When using the square cropper calculations is necessary to check if whether the square cropper is within the picture, so when you adjust the image it will fully fit the croppers and no blank spaces will appear on the generated image (which would make it quite ugly in my opinion).

But, once we go into the details, it turns out that this process isn’t so straightforward. First of all, what variables do we have here?

  • The size of the cropper square (note: this method will also work for any convex quadrilateral cropper)
  • The size of the image
  • The rotation of the image
  • The position of the image (top, left offsets)

First of all, we need some kind of coordinates to work with, because the raw information just won’t do the job. Since the cropper’s position remains the same the whole time, we need to calculate it just once.

const cropperTopLeft = {  
x: cropperStyleLeft,
y: cropperStyleTop,
}
const cropperTopRight = {
x: cropperStyleLeft + cropperWidth,
y: cropperStyleTop,
}
const cropperBottomLeft = {
x: cropperStyleLeft,
y: cropperStyleTop + cropperHeight,
}
const cropperBottomRight = {
x: cropperStyleLeft + cropperWidth,
y: cropperStyleTop + cropperHeight,
}

Now let’s take a look at the picture’s position after the positioning. We still have the top and left values of the picture, but we need to be aware that after the rotation, the top and left values refer to the corner closest to the top/left side of the screen. The closest point to the left side will be the left value, and this applies for the top as well.

Here, we start off the same way as before with the cropper, using the positions and sizes. But, on account of the rotation, we also need the center coordinates here.

const p1 = { x: left, y: top };
const p2 = { x: left + width, y: top };
const p3 = { x: left, y: top + height };
const p4 = { x: left + width, y: top + height };
const center = { x: left + (width / 2), y: top + (height / 2) };

To get the rotated points, we need to do the calculations for all the points using a 2 dimensional rotation matrix:

const rotatePoint = (center, point, angle) => {  const x = Math.round(
(Math.cos(angle) * (point.x - center.x)) -
(Math.sin(angle) * (point.y - center.y)) +
center.x
);
const y = Math.round(
(Math.sin(angle) * (point.x - center.x)) +
(Math.cos(angle) * (point.y - center.y)) +
center.y
);
return { x, y };
};

Aaaaand now we have all the coordinates after the transformations! Only one more thing to calculate, and that is whether the cropper is inside the image.

Here we use another simple mathematical principle, calculating the distance between a line and any given point using the equations for a straight line.

First of all, we need to define these lines, and on the basis of the rotation, we need to check which line goes to which cropper point. Also, since we are reassigning the line-cropperPoint pairs every 90°, we can can simplify the calculation if we only calculate using degrees between 0° and 90°.

const line1 = { x1: p3.x, y1: p3.y, x2: p1.x, y2: p1.y };
const line2 = { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y };
const line3 = { x1: p2.x, y1: p2.y, x2: p4.x, y2: p4.y };
const line4 = { x1: p4.x, y1: p4.y, x2: p3.x, y2: p3.y };
let alphaAngle = rotation;// topLeftLine goes with the cropperTopLeft point and etc.
if (rotation >= 0 && rotation <= 90) {
topLeftLine = line1;
topRightLine = line2;
bottomRightLine = line3;
bottomLeftLine = line4;
} else if (rotation > 90 && rotation <= 180) {
alphaAngle -= 90; // this will help the calculations later
topLeftLine = line4;
topRightLine = line1;
bottomRightLine = line2;
bottomLeftLine = line3;
} else if (rotation > 180 && rotation <= 270) {
alphaAngle -= 180;
topLeftLine = line3;
topRightLine = line4;
bottomRightLine = line1;
bottomLeftLine = line2;
} else if (rotation > 270 && rotation < 360) {
alphaAngle -= 270;
topLeftLine = line2;
topRightLine = line3;
bottomRightLine = line4;
bottomLeftLine = line1;
}

All set now - only one equation to go, and we can exactly find the distances between the image sides and the cropper corners. In this case, we need to run this for all 4 lines.

getDistanceBetweenLineAndPoint(point, line) {
const point = { x: point.x, y: point.y };
const lineP1 = { x: line.x1, y: line.y1 };
const lineP2= { x: line.x2, y: line.y2 };
const counter = ((lineP2.y - lineP1.y) * point.x)
- ((lineP2.x - lineP1.x) * point.y)
+ (lineP1.y * lineP2.x) - (lineP2.y * lineP1.x);
const denominator = Math.sqrt(
Math.pow(lineP2.x - lineP1.x, 2)
+ Math.pow(lineP1.y - lineP2.y, 2)
);
return (counter / denominator);
}
isCropperInsidePicture() {
const topLeft = getDistanceBetweenLineAndPoint(
cropperTopLeft, topLeftLine);
// ... repeat for the rest
return (topLeft <= 0 && topRight <= 0
&& bottomLeft <= 0 && bottomRight <= 0);
}

We can see that the original equations use the absolute function in the counter, but we don’t need that here, because if the value is positive, the point is “above” the line, so that means that the cropper is not inside the picture, but if it is negative then we are okay for that point.

So far so good! But here we can enhance this code a little more. Since we already have the distances, with this information repositioning the picture is a piece of cake!

Repositioning the image after the image was released (Android)

First we need to translate those values back onto the original axes, so we know the left/top distances from the corners to each line exactly. And with those values we can easily reposition the image. How? Just using simple trigonometry.

const topLeftDistance = { top: 0, left: 0 };
const topRightDistance = { top: 0, right: 0 };
// ...repeat for all
let counter = 0;// HACK - We don't want to divide by 0
if (alphaAngle === 90) {
alphaAngle -= 1;
}
if (alphaAngle === 0) {
alphaAngle += 1;
}
// no need for further checks because alphaAngle is between 0 and 90
const RadAlphaAngle = Math.abs(alphaAngle) * Math.PI / 180;if (topLeft > 0) {
counter++;
const left = topLeft / Math.cos(RadAlphaAngle);
const top = left / Math.tan(RadAlphaAngle);
if (left > top) {
topLeftDistance.top = top;
} else {
topLeftDistance.left = left;
}
}
if (topRight > 0) {
counter++;
const top = topRight / Math.cos(RadAlphaAngle);
const right = top / Math.tan(RadAlphaAngle);
if (right > top) {
topRightDistance.top = top;
} else {
topRightDistance.right = right;
}
}
// ...repeat for all
// We only need the biggest numbers!
const
cornerDistances = {
left: Math.max(
Math.abs(topLeftDistance.left),
Math.abs(bottomLeftDistance.left)
),
top: Math.max(
Math.abs(topLeftDistance.top),
Math.abs(topRightDistance.top)
),
// ...repeat for all
isPicInsideCropper: false,
counter,
};

And now, finally, no more calculations! Oh wait: what if we start animating the left & right / top & bottom positions simultaneously? Or we simply zoom out too much and the image will never fill up the cropper area? Okay, just a little more calculations and we are done.

To check if the image fits the cropper we need to calculate using the cropper’s diagonal.

const cropperDiagonal = Math.sqrt(
Math.pow(cropperImageSize.width, 2) +
Math.pow(cropperImageSize.height, 2)
);
const minDimension = Math.min(minHeight, minWidth);let scaleUpNeeded = croppedDiagonal > minDimension;

And if we have the left & right / top & bottom distances, then this also means that we need to scale up. You will see that we are making a little bit of correction here, to make sure that the image will move past the borders with the animation.

const ratio = originalWidth / originalHeight;
const correction = 30;
if (cornerDistances.top && cornerDistances.bottom) {
plusHeight = cornerDistances.top + cornerDistances.bottom + correction;
plusWidth = plusHeight * ratio;

scaleUpNeeded = true;
// We need disableBottom later when setting up the animations
disableBottom = true;
}

No more logical checks needed: now we can set up the animation.

// createAnimation takes 2 parameters: parameterToAnimate, toValuelet animations = [];if (cornerDistances.top) {
animations.push(createAnimation(top,
currentTopValue - cornerDistances.top - correction));
}
// ... same for left
if (cornerDistances.bottom && !disableBottom) {
animations.push(createAnimation(top,
currentTopValue + cornerDistances.bottom + correction));
}
// ... same for right
if (scaleUpNeeded) {
if (currentWidth < minHeight + plusWidth) {
animations.push(createAnimation(width, minWidth + plusWidth));
}
// ... same for height
}
animations.start(callbackFunction)

In the callbackFunction you need to check again what happened after the animation. Here you can run all the checks again if the image is now inside the cropper. If the image is still not inside the cropper, you can call this function again to create a new animation, just make sure you don’t end up in infinite loops!

So, overall, you don’t need any complex mathematical knowledge to get a deep understanding of these kinds of functionalities, you just need to get all those equations from all over the internet, recall your schooldays memories, classes and put them in the right places. Sure, school was a long time ago, and it is difficult to get back those memories, but I am pretty sure that you can recall that phrase you said long time ago in a class just like this: “Why the hell do I need to study all this stupid things when I am not gonna use them anyway?”

--

--