Use two fingers to pinch, zoom, move and rotate the rocket above.

Pinch, Zoom, Move and Rotate

When using your phone or a tablet device you may have used two fingers to zoom in and out of an image. This method of controlling an image with your two fingers has become such a common method that even children find it simple to do. But you can do more than just zoom in and out. You can use your two fingers to rotate and move the image around too.

We would normally do this only with images but I wanted to look at this from a coordinate space point view. Instead of just adjusting an image, we are going to imagine a grid that we can move around, scale in and out of and rotate. We can then use the coordinate space to place images onto, draw HTML canvas objects, SVG images, or even fractals.

We will be looking at how this can be done on a web page. You can only do this type of thing on a touch enabled device that is showing the web page. There are no touch options on a normal desktop computer, so you are limited to only using one touch (the mouse pointer).

We are going to get into some HTML, CSS, JavaScript and some math. I have also created some code to help you to do this type of thing within your own application.

View space and coordinate space

On the web page there will be a HTML element that will allow the user to use their two fingers to change the underlying coordinate space behind it. The x and y pixel locations within the element are from the top left corner and move to the bottom right. What we want, when working out touch events, is to translate the pixel points into the view space.

The view space has its center in the middle of the HTML element instead of it being in the top left corner. Pixels to the right of the center are positive and pixels to the left are negative.

Without any adjustments made to the coordinate space, its center will match up with the view space's center. Also, each pixel will be 1 unit long within the coordinate space. The coordinate space can be moved, scaled and rotated. The order of these adjustments are important. We move first, then scale and then finally rotate.

When you use your two fingers within the view space, the coordinate space within will be adjusted to reflect the new adjustments being made. The end result will be the following 4 values.

  • Coordinate Space X
  • Coordinate Space Y
  • Coordinate Space Scale
  • Coordinate Space Rotate

You will need to use these values to draw whatever is on the coordinate space within the view space HTML element. We will look into how this is done later.

Using touch on a web page

To get the HTML element ready for someone to use with their two fingers, we need to set it up using some CSS. By default, if you use your two fingers to pinch and zoom the element then the whole web page will scale in and out. Any two finger adjustments will automatically be controlled by the browser and will, by default, zoom in and out the whole page.

We need to tell the browser that the HTML element is not part of the whole page zoom process and that we will be controlling all touch events ourselves. This is done using the CSS property touch-action set to none. This turns off all touch action to the element. We still receive JavaScript events, which we will be processing manually ourselves.

touch-action: none;

We now need to get and handle all the touch events that the user makes within the HTML element. There are 3 events we can use, touchstart, touchmove and touchend. The event information contains a list of all the touches being made. For two fingers there will be a list of two touch objects, giving the X, Y location for each.

What we will be doing is handling these events and processing the list of touches, to work out where the two fingers started, where they currently are and what change has been made to the coordinate space behind it. To help us handle all the events and perform all the calculations I have created a touch-tools.js file. The code shown below can be found within this file.

The first function we will look at is the one that handles the touchStart event. In your own code you will need to create the event listener for the “touchstart” event and pass the list of touches into the following function.

touchStart(touchList) {
    // Set default touch count
    this._touchCount = -1;

    // If touch count is not 1 or 2
    if (touchList.length !== 1 && touchList.length !== 2) return;

    // Set touch count
    this._touchCount = touchList.length;

    // Set start touch 1 points
    this._startTouch1X = touchList[0].clientX;
    this._startTouch1Y = touchList[0].clientY;

    // If 2 touches
    if (this._touchCount === 2) {
        // Set start touch 2 points
        this._startTouch2X = touchList[1].clientX;
        this._startTouch2Y = touchList[1].clientY;
    }

    // Save start coordinate values
    this._startCoordinateSpaceX = this._coordinateSpaceX;
    this._startCoordinateSpaceY = this._coordinateSpaceY;
    this._startCoordinateSpaceScale = this._coordinateSpaceScale;
    this._startCoordinateSpaceRotate = this._coordinateSpaceRotate;
}

We can only handle 1 or 2 touches at once. Any more than 2 touches (fingers) at once and nothing will happen. We keep a count of the number of touches that were used at the start and we only continue to process the touches if the same number of touches are being used. So, if you started with only one finger touching and then added another a second later, nothing will happen.

We then keep a record of the start touch X, Y locations for the first touch and the second (if two are being used). We will need to know where the starting locations of the touching points were, so that when the fingers move we can calculate what changes have been made.

We also need to record what the current coordinate space adjusts are before any extra adjustments are made.

Now we will look at what happens when the touch move event happens.

touchMove(touchList) {
    // If the touch counts do not match
    if (this._touchCount !== touchList.length) return;

    // Set end touch 1 points
    this._endTouch1X = touchList[0].clientX;
    this._endTouch1Y = touchList[0].clientY;

    // If 2 touches
    if (this._touchCount === 2) {
        // Set end touch 2 points
        this._endTouch2X = touchList[1].clientX;
        this._endTouch2Y = touchList[1].clientY;
    }

    // Update current coordinate value
    this._updateCoordinateSpace();
}

First we check that the number of touches at the start are still the same now. If a finger was added or removed then we cannot handle that and therefore stop processing the adjustments.

We then record the end touch X, Y locations for the first touch and the second (again, if two are being used). With both the starting locations and the new current locations of the touches, we can calculate adjustments made to the coordinate space within it. That process is going to take some math and some more code.

Touch to view space

We have the starting and current ending touch locations. With this information we can start the process of calculating the new coordinate space adjustments that have been made. This is done within the updateCoordinateSpace function.

The first main thing we need to do is convert those touch points from view point values into the view space values. The touch locations are given in X, Y points from the browser view point, and we want them in the view space.

Here you can see where a touch was made. The X, Y point is the offset from the top left of the view point. This needs to be converted into the view space values. The function that does this first needs to get the bounding rectangle of the HTML element that is being touched.

_updateCoordinateSpace() {
    // STEP 1: Get touch element dimensions (related to the viewport)
    this._touchElementRect = this._touchElement.getBoundingClientRect();
    
    // STEP 2: Convert the touch X/Y points (in client/element space) into view space vectors
    this._touchToViewSpace();

    // STEP 3: Workout center points
    this._workoutTouchCenter();

    // STEP 4: Workout the new coordinate space adjustments
    this._workoutCoordinateSpace();
}

The first thing we do is to get the bounding rectangle of the HTML element we are touching. With this and the touch points we can calculate the view space locations.

_touchToViewSpace() {
    // Convert touch 1 points into vectors in view space
    this._viewSpaceStartTouch1 = new Vector(
        this._touchToViewSpaceX(this._startTouch1X),
        this._touchToViewSpaceY(this._startTouch1Y)
    );
    this._viewSpaceEndTouch1 = new Vector(
        this._touchToViewSpaceX(this._endTouch1X),
        this._touchToViewSpaceY(this._endTouch1Y)
    );

    // If not two touches then stop here
    if (this._touchCount !== 2) return;

    // Convert touch 2 points into vectors in view space
    this._viewSpaceStartTouch2 = new Vector(
        this._touchToViewSpaceX(this._startTouch2X),
        this._touchToViewSpaceY(this._startTouch2Y)
    );
    this._viewSpaceEndTouch2 = new Vector(
        this._touchToViewSpaceX(this._endTouch2X),
        this._touchToViewSpaceY(this._endTouch2Y)
    );
}

It looks complex but what is happening is that we are creating vectors for the first touch point at the start and end locations, within the view space. If there is more than one touch then we do the same for the second touch (finger).

This is what we will end up with if two fingers are used. The starting location of touch 1 and touch 2, and the ending locations of both touches. How these vectors are used to workout the new adjustments to the coordinate space is difficult to explain, and I'm not totally sure myself, but we will see how far we get.

One of the important things to remember while we move forward, is that the center point between the touches will always be exactly the same point within the coordinate space. No matter how much you move, scale or rotate, the center point is always the same.

How to handle single touch movement

Moving a single touch (finger) from the starting point to the ending point is simple. All you are doing is adjusting the coordinate space X, Y parts in relation to the touch adjustments.

The touch movement is in view space, so we need to move it into the current coordinate space. This way the adjustment made is at the same scale and the movement is proportional.

// Workout the touch movement
const touchMovement = new Vector(
    this._viewSpaceEndTouchCenter.x - this._viewSpaceStartTouchCenter.x,
    this._viewSpaceEndTouchCenter.y - this._viewSpaceStartTouchCenter.y
);

// Scale the touch movement into the current coordinate space
touchMovement.scale(1 / this._startCoordinateSpaceScale);

// Adjust the coordinate space XY parts by the touch movement
this._coordinateSpaceX = this._startCoordinateSpaceX + touchMovement.x;
this._coordinateSpaceY = this._startCoordinateSpaceY + touchMovement.y;

Here we are creating a vector of the movement made in the view space, from the starting touch to the ending touch. We then reverse scale the vector into the coordinate space. We are using the starting coordinate scale to do this. After this the movement vector will be scaled into the coordinate space and the X, Y adjustments can be added to the starting X, Y points. The end result is the new adjusted coordinate space X, Y values.

We do not need to look at any scaling or rotation because that cannot happen with only one touch being used.

How to handle touch movement

You can scale, rotate and move the coordinate space with your fingers, but the order in which you do them is not important. You can scale an image, then move it and you will get the same end result as if you had done the same thing but moved it first then scaled it. Therefore, if the order does not matter, we can handle the movement on its own first and then process and scale and rotations made later.

When using two touch points (fingers) we create the center point between the two and use that for calculations. We continue much like moving with a single touch. All the calculations are the same. However, we do continue to look at and scale and rotation that could have happened.

How to handle scaling

When you move your fingers apart you are zooming in, and when you are moving your fingers towards each other, you are zooming out. To work out how much you have zoomed in or out, how much you are adjusting the scale, can be done by looking at the distance between the touch points at the start and comparing that to the distance between them at the end.

// Get start distance
const startDistanceX = Math.abs(this._viewSpaceStartTouch1.x - this._viewSpaceStartTouch2.x);
const startDistanceY = Math.abs(this._viewSpaceStartTouch1.y - this._viewSpaceStartTouch2.y);
const startDistance = Math.sqrt((startDistanceX * startDistanceX) + (startDistanceY * startDistanceY));

// Get end distance
const endDistanceX = Math.abs(this._viewSpaceEndTouch1.x - this._viewSpaceEndTouch2.x);
const endDistanceY = Math.abs(this._viewSpaceEndTouch1.y - this._viewSpaceEndTouch2.y);
const endDistance = Math.sqrt((endDistanceX * endDistanceX) + (endDistanceY * endDistanceY));

// Workout adjustment scale
let adjustmentScale = endDistance / startDistance;  

If the distance has doubled then you have scaled up, zoomed in, by x2 the amount. So scaling looks easy to do, just work out the scale amount and apply it to the coordinate space scale value. But that isn't all we need to do. We have to make sure the touch center point after scaling is still in the same location within the view space.

Here you can see that after scaling the center point has moved to the left. We need to make an extra adjustment to move that current coordinate space X, Y parts too, so that the touch center point looks like it hasn't moved.

// Set from point
let from = this._viewSpaceEndTouchCenter.clone();

// Scale down into current coordinate space
from.scale(1 / this._coordinateSpaceScale);

// Set starting to point
let to = from.clone();

// Scale the to point
to.scale(adjustmentScale);

// Workout the adjustment made by scaling
const scaleAdjustment = new Vector(
    from.x - to.x,
    from.y - to.y);

// Scale into starting coordinate space
scaleAdjustment.scale(1 / adjustmentScale);

// Adjust the coordinate space XY parts by the scale
this._coordinateSpaceX += scaleAdjustment.x;
this._coordinateSpaceY += scaleAdjustment.y;  

To work out the extra movement required, we first create a vector (called from) to the center starting touch points (in view space) and scale that into the current coordinate space, then we clone it (called to) and perform the scale adjustment to it. With these two vectors we can calculate the movement that was made (called scaleAdjustment). However, this adjustment needs to be upscaled back into the starting coordinate space. After all this we have the amount the center point needs to be adjusted by to make sure it remains in the same location in the view space.

How to handle rotation

When you move your fingers in a circular motion, either keeping one finger still while moving the other around it, or by twisting them around each other, you end up performing a rotation. To work out how much you have rotated, you need to work out the angle of the two finger points at the start and compare them to the angle of the two fingers at the end points.

// Workout the start angle (in radians)
const startAngle = Math.atan2(
    (this._viewSpaceStartTouch1.x - this._viewSpaceStartTouch2.x),
    (this._viewSpaceStartTouch1.y - this._viewSpaceStartTouch2.y));

// Workout the end angle (in radians)
const endAngle = Math.atan2(
    (this._viewSpaceEndTouch1.x - this._viewSpaceEndTouch2.x),
    (this._viewSpaceEndTouch1.y - this._viewSpaceEndTouch2.y));

// Workout the adjustment rotate (in radians)
let adjustmentRotate = endAngle - startAngle;  

Like scaling, we need to do some more work to make sure the touch center point remains in the same location within the view space.

Here the rotation has moved the touch center point towards the right. We need to move the current coordinate space X, Y parts so that the touch center point is back in the same place in the view space.

// Set from point
from = this._viewSpaceEndTouchCenter.clone();

// Scale down into current coordinate space
from.scale(1 / this._coordinateSpaceScale);

// Set starting to point
to = from.clone();

// Set coordinate space center
const center = new Vector(this._coordinateSpaceX, this._coordinateSpaceY);

// Perform the adjustment rotate (around the coordinate center)
to.translate(-center.x, -center.y);
to.rotate(adjustmentRotate);
to.translate(center.x, center.y);

// Workout the adjustment made by rotation
let rotateAdjustment = new Vector(
    to.x - from.x,
    to.y - from.y);

// Rotate into starting coordinate space
rotateAdjustment.rotate(-adjustmentRotate);

// Adjust the coordinate space XY parts by the rotation
this._coordinateSpaceX += rotateAdjustment.x;
this._coordinateSpaceY += rotateAdjustment.y;

// Set the new coordinate space rotation to take rotate adjustment into account
this._coordinateSpaceRotate += adjustmentRotate;  

Working out the extra movement required is a little more tricky to do. Similar to the scaling process, we need to first create a vector (called from) to the center starting touch points (in view space) and scale that into the current coordinate space. Then we clone it (called to) and rotate it around the center of the coordinate space. With these two vectors we can calculate the movement that was made (called rotateAdjustment). However, this adjustment needs to be reversed-rotated back into the starting coordinate space. Now we have the final adjustment needed to make sure the center point is in the same view space location.

Using with canvas

With everything we have created so far, we can use it to allow someone to adjust something we draw within a canvas, to zoom in and out, move around and rotate. To get everything working requires a number of steps, which I have put together into a web component.

Download: touch-canvas.js

Above is the example up and running. If you are using a touch device you can use your fingers to make adjustments, to move, scale and rotate the canvas drawing of a square.

The first thing you need to do is create the HTML parts. Because we are using a web component we use the touch-canvas tag. We also need to add some styling.

<touch-canvas
  style="
    touch-action: none;
    display: block;
    width: 300px;
    height: 400px;
    border: 1px solid gray;">
</touch-canvas>

In the web components constructor we perform a number of setup steps. We need to set up the touch tools object. Here we are creating the object, setting the canvas element and also setting the starting coordinate space adjustments. In this example we set the scale to be half the width of the canvas.

// Set touch tools
this._touchTools = new TouchTools();

// Set touch element
this._touchTools.setTouchElement(this._canvasElement);

// Set starting coordinate space adjustments
this._touchTools.setCoordinateSpace(0, 0, this._canvasElement.width / 2, 0);  

When the web component is attached to the DOM it calls the connectCallback method. In here we need to make sure the canvas' internal width and height are set to be the same as the width and height of the canvas element itself.

connectedCallback() {
  // Set the canvas element width and height to match client width and height
  this._canvasElement.width = this._canvasElement.clientWidth;
  this._canvasElement.height = this._canvasElement.clientHeight;
  ...

We also need to set up the event processing. The touch element will get touchstar and touchmove events which we will need to pass on to the touch tools object. Each time the touchmove event is fired we also need to draw the coordinate space object.

// Add touch events
this.addEventListener('touchstart', this._touchStartEvent, { passive: true });
this.addEventListener('touchmove', this._touchMoveEvent, { passive: true });
_touchStartEvent(event) {
    // Call touch start event with list of touches
    this._touchTools.touchStart(event.touches);
}

_touchMoveEvent(event) {
    // Call touch move event with list of touches
    this._touchTools.touchMove(event.touches);        

    // Draw the coordinate space in the canvas (after the touch adjustments are made)
    this._drawCoordinateSpace();
}

Finally we come to the part where we draw the coordinate space object on the canvas. In the example given we first clear the whole canvas area. After this we need to adjust the whole canvas area, moving it into the coordinate space.

// Clear the canvas to start drawing on it
this._canvasContext.save();
this._canvasContext.fillStyle = '#F1F1F1';
this._canvasContext.fillRect(0, 0, this._canvasElement.width, this._canvasElement.height);

After this we can draw all the canvas parts using coordinate space values. In the below part we are drawing a square from top left (-0.5, 0.5) to bottom right (0.5, -0.5).

this._canvasContext.beginPath();
this._canvasContext.lineWidth = size / 4;
this._canvasContext.strokeStyle = '#303030';
this._canvasContext.moveTo(-0.5, 0.5);
this._canvasContext.lineTo(0.5, 0.5);
this._canvasContext.lineTo(0.5, -0.5);
this._canvasContext.lineTo(-0.5, -0.5);
this._canvasContext.lineTo(-0.5, 0.5);
this._canvasContext.stroke();

Using with image

Using touch adjustments on an image requires some extra work. We will be using a canvas to draw the image on, so we will be using everything we have used so far. For the example below, I have put everything needed into a web component, where all you need to do is supply the URL of the image to use (plus some CSS styling).

Download: touch-image.js

The example above is up and running. If you are viewing this on a touch device then you can use your fingers to move, scale and rotate the image.

The first thing you need to do is create the HTML parts. As this is a web component we have the touch-image tag with some styling and the image URL attribute.

<touch-image
  style="
    touch-action: none;
    display: block;
    width: 300px;
    height: 400px;
    border: 1px solid gray;"
  image="/image/waiting-robot.jpg">
</touch-image>

Have a look inside the web component's source code. You will see that when the element is added to the DOM it will load the image. After we have loaded in the image, which is put into an Image object, we need to work out what the starting coordinate space scale should be, so that the whole image is shown. We have the images width and height and the canvas' width and height, so we can calculate the ratio of the width and height, and then use the smallest one as the scale value.

// Workout the width and height ratios
let widthRatio = this._canvasElement.width / this._image.width;
let heightRatio = this._canvasElement.height / this._image.height;

// Set starting zoom
let startingZoom = widthRatio;
if (heightRatio < widthRatio) startingZoom = heightRatio;

// Set starting coordinate space adjustments
this._touchTools.setCoordinateSpace(0, 0, startingZoom, 0);  

When it comes to drawing the image, it is similar to the canvas process we used before.

// Clear the canvas to start drawing on it
this._canvasContext.save();
this._canvasContext.fillStyle = '#F1F1F1';
this._canvasContext.fillRect(0, 0, this._canvasElement.width, this._canvasElement.height);

// Move the canvas into the middle of the view space
this._canvasContext.translate(this._canvasElement.width / 2, this._canvasElement.height / 2);

// Scale, translate and rotate the canvas into the coordinate space
this._canvasContext.scale(this._touchTools.coordinateSpaceScale, this._touchTools.coordinateSpaceScale);
this._canvasContext.translate(this._touchTools.coordinateSpaceX, -this._touchTools.coordinateSpaceY);
this._canvasContext.rotate(this._touchTools.coordinateSpaceRotate);

// Move center of image
this._canvasContext.translate(this._image.width / -2, this._image.height / -2);

// Draw the image
this._canvasContext.drawImage(this._image, 0, 0);

// End drawing
this._canvasContext.restore(); 

First we clear the canvas area. Then we move the canvas to the center. After that we adjust the canvas to the coordinate space using the touch tools adjustment values. Then we move by half the width and height, so that the center of the image will be in the center of the canvas. And finally we draw the image itself.

Using with SVG

When it comes to using SVG images, and touch adjustments, again some addictional work is required. An SVG cannot be used with a canvas like the image example, so we need to use its built-in transform attribute to adjust it with the touch adjustments. In this example, I have put everything together in a web component, with an image attribute that is used to set the SVG URL path. It will also require some extra styling.

Download: touch-svg.js

The above example is live and can be used. If you are using a touch device to view this page then you can adjust it using two fingers, to move, scale and rotate the SVG.

The first requirement is for you to set the HTML parts. Because this is a web component we use the touch-svg tag with some extra styling and the image URL attribute pointing to the SVG.

<touch-svg
  style="
    touch-action: none;
    display: block;
    width: 300px;
    height: 400px;
    overflow: hidden;
    border: 1px solid gray;"
  image="/image/rocket-ship.svg">
</touch-svg>

Take a look at the source code inside the web component. When the element is added to the DOM we fetch the SVG image as text. When we get the SVG text, we add it to the shadow DOM, but after this we need to do some extra work, to set it up in a way that allows us to start adjusting it.

// Get the SVG image as text
fetch(this.getAttribute('image'))
.then(r => r.text())
.then(text => {
    // Add the SVG to the shadow DOM
    this.shadowRoot.innerHTML = text;

    // Get the SVG element
    this._svgElement = this.shadowRoot.querySelector('svg');

    // Remove any width or height attributes
    this._svgElement.removeAttribute('width');
    this._svgElement.removeAttribute('height');

    // Add some extra styling to scale the SVG to fit within the shadow DOM
    this._svgElement.style.display = 'block';
    this._svgElement.style.height = '100%';  

We need to remove any width and height attributes that may already exist. We are going to have the SVG's height set to be the same as the web component, so that it fills the whole parent element. We do this by setting the height style to 100% (of the parent's height).

// Workout the starting zoom amount
let startingZoom = 1;

// Check the client's height/width and the SVG's height/width
if (this.clientHeight > this.clientWidth) {
    if (this._svgElement.viewBox.baseVal.height > this._svgElement.viewBox.baseVal.width) {
        startingZoom = 1;
    } else {
        startingZoom = this.clientWidth / this._svgElement.clientWidth;
    }
} else {
    if (this._svgElement.viewBox.baseVal.height > this._svgElement.viewBox.baseVal.width) {
        startingZoom = this.clientHeight / this._svgElement.clientHeight;
    } else {
        startingZoom = this.clientWidth / this._svgElement.clientWidth;
    }
}

// Set starting coordinate space adjustments
this._touchTools.setCoordinateSpace(0, 0, startingZoom, 0);

We then need to workout the starting zoom amount so that the SVG will fit within the parent. This is a little complex, but we want the SVG's height to match the parent and then adjust the starting zoom to make sure either the height or width of the SVG can be seen within the parent.

The final thing we need to do is to adjust the SVG with the coordinate space adjustments. We do this in the _adjustSvg method. In the root SVG object we can include the transform attribute, which allows us to move, scale and rotate the whole SVG object.

// Set rotate in dagress
const degrees = this._touchTools.coordinateSpaceRotate * 180 / Math.PI;

// Set center adjustments
let centerAdjustmentX = (this.clientWidth - this._svgElement.clientWidth) / 2;
let centerAdjustmentY = (this.clientHeight - this._svgElement.clientHeight) / 2;

// Set transform text
const transformText = 
    'translate(' + centerAdjustmentX.toString() + ' ' + centerAdjustmentY.toString() + ') ' +
    'scale(' + this._touchTools.coordinateSpaceScale.toString() + ' ' + this._touchTools.coordinateSpaceScale.toString() + ') ' +
    'translate(' + this._touchTools.coordinateSpaceX.toString() + ' ' + -this._touchTools.coordinateSpaceY.toString() + ') ' +
    'rotate(' + degrees.toString() + ')';

// Set the transform to move the SVG to the coordinate space adjustment
this._svgElement.setAttribute('transform', transformText);

We need to make an adjustment to center the SVG in the middle of the element. After this we simply perform the transformations required to adjust the SVG image with the coordinate space adjustments made by the touch changes.

Notes

It is possible there is a better method of doing all this using math and matrices or something. All the calculations and methods used are from me, a software developer with limited math skills, and therefore is properly not the best solution. Still, it works, which is the important part.

Feel free to download and use the code as you need.

Download: touch-tools.js Download: vector.js Download: touch-canvas.js Download: touch-image.js Download: touch-svg.js