Zoom and pan in Three.js customly — Simple Interaction in Data & Graph Visualization

Simone Ceccarelli
16 min readMay 18, 2022

Just For Fun

This article, the code contained and any repositories linked here, were created with the Just For Fun policy

Pre Intro

If you are only interested in using zoom and pan in your application and don’t want to read the article, you can install Compass directly. In this article I will show you the logic inside the Compass

If you need a project already configured with Three.JS and Typescript you can fork/download/….

Se questo boiler di Three.JS + Typescript ti piace, lascia una stellina su github.

If you like this Three.JS + Typescript boilerplate, drop a star on github.

Interaction

When viewing things on the screen, no matter what (diagram, image, floor plan), it is important to ensure interactions to support what you are viewing, also because nowadays everything is complicated and difficult to understand and many visualization techniques sponsor the interaction with visualization in order to fully understand it.

The interaction is at the heart of modern information visualization, and all of us have come to understand something better, simply by changing his point of view. This is not meant to be a lesson in information visualization but I think it is interesting to mention [Yi Soo Yi et al., 07] where they write “In total, we surveyed 59 papers and 51 systems and collected 311 individual interaction techniques actually implemented in Infovis systems”. They therefore identified 311 interaction techniques understanding what a user wants to achieve through a specific interaction technique. They have classified them in 7 different categories, today we are interested in two of these, the Zoom and part of the Explore which contains the panning.

Zoom

Adjust the level of the abstraction, fundamentally without changing the representation.

Zooms are used in many different ways, one of the main techniques being the famous zoom and details.

The ways in which they are used are the most diverse

  • “+” button that zooms-in to the center (or “-” for zooms-out to the center)
  • using the mouse wheel to zoom to the center or to a specific area
  • selecting an application square and zooming in on it
  • zoom only on the tip of the mouse with a fish eye-like technique
  • ………………………………..

In this article I want to introduce a type of zoom very similar to that of google maps.

Let’s understand the behavior we want to reproduce.

We want to zoom in on a specific area using the cursor position.

Starting from a starting point like this

Figure 1

by zooming in gradually, you can get to this

Figure 2

we are zooming in on a specific area, that of our cursor.

Important observations we can make about visualization after the zoom

  • changes the depth of the camera
  • change the center of the camera
  • the absolute position of the mouse always remains the same

Ideally, with every ‘wheel’ movement we have to

  • vary the depth of the camera (z axis)
  • vary the center of the camera on the x and y axes
  • make sure that the mouse is always over the same position on the canvas

More simply we have to modify the coordinates of the camera so that the mouse remains in the same position within the canvas.

Let’s understand it a little better, simplifying, let’s consider two coordinate spaces

  • The world =>all observable space, where its size is given by the resolution of our screen
    - x axis=> [0, maxWidth]
    - y axis=> [0, maxHeight]
  • The canvas =>space actually displayed on the canvas, this space depends on many factors
    - The depth in the z axis
    - The fov
    - The aspect
    - The size in pixels of the canvas height and width;

(if these concepts are new to you, go to the appendix at the end of the article)

Let’s understand how to calculate the size of the canvas space

  • y axis => heigth = tangent(fov * PI / 180 / 2) * z * 2
  • x axis => width = heigth * aspect

now we understand better tangent(fov * PI / 180 / 2) * z * 2

  • the FOV is expressed in degrees, so the equivalent in radians is FOV * PI / 180
  • as can be seen from figure 3, the height is twice the tangent expressed in figure 4
Figure 3
Figure 4

Let’s suppose now to zoom in on the center of the camera, what happens to our spaces?

  • the world remains the same, its coordinates do not depend on the canvas
  • lo spazio del canvas invece cambierà i suoi intervalli, come vediamo nella formula che lo definisce, essa dipende dall’asse z della camera e andando a zoommare la prima cosa che cambieremo è proprio quella
  • the space of the canvas will change its intervals, as we see in the formula that defines it, it depends on the z axis of the camera and by zooming in, the first thing we will change is precisely that

if for example, we take fov = 30, aspect = 2 and z = 300

  • height = 160.8
  • width = 321.5

if we zoomed in, therefore decreasing the z for example to 200, we will have that

  • height = 107.2
  • width = 214.4

Considering that our goal is to zoom in on a specific area and to do this we must ensure that the cursor always remains in the same coordinates in the space of the canvas then the logic is to go to understand how much the mouse has moved in the space of the canvas and move the camera of that offset.

Before central zoom

Figure 5

after central zoom

Figure 6

as you can see, following a shift of the z axis the dimension of the canvas space changes (not the canvas pixels) which changes the coordinates of the elements in the drawing with respect to the coordinates of the world. So even though our mouse did not move relative to the world space, it did move in the coordinates of the canvas.

At this point it is clear that in order to zoom in a specific position we must, together with the variation of the z axis, translate the x and y axis of the camera to compensate for this distance that has been created.

Technical Implementation

We start from a project already configured and gradually add the various pieces.
I recommend not focusing so much on the code already written, as there are better examples to start with Three.js, but spend more time on the code that we will write together.

In particular you can use this sanbox

or you can fork the parent repository

this repository wants to be a boilerplate for three.js + typescript always with just for fun logic (if you find it interesting, leave a ⭐ it’s the only way I have to understand if someone is interested in the project)

At this point we must

  • take the mouse positions (xWorld, yWorld)
  • calculate the corresponding coordinates in the canvas (xCanvas, yCanvas)
  • zoom in
  • calculate the new positions of the mouse, in the space of the canvas, so as to reposition it under the world coordinates

Let’s create a class that manages our zoom; I will call her Compass. Considering the points just written, we definitely need it

  • the camera
    - for the current values of z, x e y
    -
    to change the values ​​of z, x e y
    - to understand the size of the canvas using z, fov e aspect
  • the renderer
    - to convert locations from world to canvas

from here we have that

import * as THREE from "three";class Compass {
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;

constructor(
camera: THREE.PerspectiveCamera,
renderer: THREE.WebGLRenderer
) {
this.camera = camera;
this.renderer = renderer;
}
}
export default Compass;

Now let’s go through the steps listed above one by one

  • take the mouse positions (xWorld, yWorld)

to do this, we need to add an event listener to the canvas, since we plan to manage multiple events on the canvas, let’s create a point that can manage them all

import * as THREE from "three";class Compass {
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;

constructor(
camera: THREE.PerspectiveCamera,
renderer: THREE.WebGLRenderer
) {
this.camera = camera;
this.renderer = renderer;
this.setAllEvents();
}

setAllEvents = () => {
this.setZoomHandler();
}


setZoomHandler = () => {
this.renderer.domElement.onwheel = (event: WheelEvent) => {
event.preventDefault();
this.zoom(event);
};
};
private zoom = (event: WheelEvent) => {
const { clientX, clientY } = event;
........................................
}
}export default Compass;
  • setAllEvents: set all events, in this case only the zoom
  • setZoomHandler: take the canvas using renderer.domElement and sets a function to be called when the onwheel event occurs
  • zoom: it is the function that we have to implement in order for the application to zoom in, in particular, clientX and clientY represents our coordinates of the world

We have therefore satisfied the first point, taking the coordinates of the world now

  • calculate the corresponding coordinates in the canvas (xCanvas, yCanvas)

Now the problem we face is to translate positions from one coordinate space to another, the simplest way that came to my mind is to translate coordinates into percentages, then

  • calculate at what percentage of the screen the current clientX and clientY coordinates are, so the coordinates of the world
  • calculate the size of the canvas space, based on fov, aspect and z
  • calculate the new positions using the percentages and the size of the canvas space

Practical example

  • we assume that the canvas in the world has a size of width = 1000 and height = 1000
  • clientX = 800 and clientY = 800
  • the percentage is
    - clientX = 800 / 1000 = 0.80 => 80% del canvas sulle x
    - clientY = 800 / 1000 = 0.80 => 80% del canvas sulle y
  • assuming a size of the canvas space equal to width = 500 and height = 500 we have
    - canvasX (clientX in the canvas) => 500 * 0.8 = 400
    - canvasY (clientY in the canvas) => 500 * 0.8 = 400

For the percentage

At this point I apologize for making fun of you 😊. So far we have talked about two spaces

  • world
  • canvas

as regards that of the canvas, what we have considered is the absolute space of the latter, generally the most used, but in this case it is convenient to use the real space of the canvas and not the absolute one (the motivation is a bit long and I prefer not to stretch the article too much)

What do I mean by absolute space (figure 7)

Figure 7

However, this is not the real space of the canvas, when we assign the coordinates to our objects, we do it on the space represented in figure 8

Figure 8

Let’s now define what we mean by percentage in the canvas space, generally there is a tendency to prefer such a situation (figure 9), everyone can choose the structure they prefer

Figure 9

we must therefore take the percentage from 0 to 1 and transform it from -1 to 1. The simplest way is surely to multiply the first percentage by 2 obtaining a value from 0 to 2 and then subtract 1.

We then add the method to class Compass

getWorldToCanvasPercentCoordinates = (wx: number, wy: number) => {
const canvas = this.renderer.domElement;
const percentX = (wx / canvas.clientWidth) * 2 - 1;
const percentY = -(wy / canvas.clientHeight) * 2 + 1;
return { percentX, percentY };
};

where wx is the x coordinate with respect to the world e wy is the y coordinate with respect to the world.

At this point we need to understand how big the real space of the canvas is and then use the percentages just found and find the position of the coordinates in the canvas.

To do this we need the

  • z
  • fov
  • aspect

all parameters of the camera and then we must proceed as illustrated in the theory at the beginning of this article (for a better understanding go to read)

  • y axis=> heigth = tangent(fov * PI / 180 / 2) * z * 2
  • x axis=> width = heigth * aspect

we are therefore taking the absolute size of the canvas

getABSBounds = () => {
const fov = this.camera.fov;
const aspect = this.camera.aspect;
const z = this.camera.position.z;
const halfHeight = Math.tan((fov * Math.PI) / 180 / 2) * z;
const halfWidth = halfHeight * aspect;
return { width: halfWidth * 2, height: halfHeight * 2 };
};

now we are going to calculate the positions in the space of the canvas

getWorldToCanvasCoordinates = (wx: number, wy: number) => {
const { percentX, percentY } =
this.getWorldToCanvasPercentCoordinates(wx, wy);
const { x, y } = this.camera.position;
const { width, height } = this.getABSBounds();
const retX = x + (width / 2) * percentX;
const retY = y + (height / 2) * percentY;
return { x: retX, y: retY };
};

in particular width/2 and height/2 as shown in figure 9, the 100% it is given by half width and height as a consequence of the fact that the center point is 0,0

Now let’s see how our class Compass looks like

import * as THREE from "three";class Compass {
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;

constructor(
camera: THREE.PerspectiveCamera,
renderer: THREE.WebGLRenderer
) {
this.camera = camera;
this.renderer = renderer;
this.setAllEvents();
}

setAllEvents = () => {
this.setZoomHandler();
}

setZoomHandler = () => {
this.renderer.domElement.onwheel = (event: WheelEvent) => {
event.preventDefault();
this.zoom(event);
};
};
getWorldToCanvasPercentCoordinates = (wx: number, wy: number) => {
const canvas = this.renderer.domElement;
const percentX = (wx / canvas.clientWidth) * 2 - 1;
const percentY = -(wy / canvas.clientHeight) * 2 + 1;
return { percentX, percentY };
};
getABSBounds = () => {
const fov = this.camera.fov;
const aspect = this.camera.aspect;
const z = this.camera.position.z;
const halfHeight = Math.tan((fov * Math.PI) / 180 / 2) * z;
const halfWidth = halfHeight * aspect;
return { width: halfWidth * 2, height: halfHeight * 2 };
};
getWorldToCanvasCoordinates = (wx: number, wy: number) => {
const { percentX, percentY } =
this.getWorldToCanvasPercentCoordinates(wx, wy);
const { x, y } = this.camera.position;
const { width, height } = this.getABSBounds();
const retX = x + (width / 2) * percentX;
const retY = y + (height / 2) * percentY;
return { x: retX, y: retY };
};
private zoom = (event: WheelEvent) => {
const { clientX, clientY } = event;
........................................
}
}export default Compass;

Let’s continue with the next point

  • zoom in

So let’s write the zoom method.

We have to take the current zoom, increase it by the zoom we decide and then update the camera. The zoom increment must be commensurate with the user’s touch

  • if you zoom in slowly, the zoom factor will be small
  • if you zoom in fast, the zoom factor will be larger

We also introduce a maximum and minimum zoom. So first let’s add an environment variable that contains these values

  • min zoom
  • max zoom
  • zoom factor
import * as THREE from "three";class Compass {
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private zoomOptions: {minZoom: number; maxZoom: number; step:
number};
constructor(
camera: THREE.PerspectiveCamera,
renderer: THREE.WebGLRenderer
) {
this.camera = camera;
this.renderer = renderer;
this.zoomOptions = {
maxZoom: 2,
minZoom: 10000,
step: 0.06,
};

this.setAllEvents();
}
................................
................................
}

So let’s update the zoom

zoom = (event: WheelEvent) => {
const { clientX, clientY, deltaY } = event;
const { step, minZoom, maxZoom } = this.zoomOptions;
const { z: zp } = this.camera.position;
let nz = zp;
nz += deltaY * step;
if (nz < maxZoom) {
nz = maxZoom;
} else if (nz > minZoom) {
nz = minZoom;
}
this.camera.position.setZ(nz);
};

The most important thing to note is nz += deltaY * step; where deltaY is the one who allows us to understand the amount of zoom based on the user’s touch.

now we are able to zoom only in the center of the canvas, so we need to implement the last point

  • calculate the new positions of the mouse, in the space of the canvas, so as to reposition it under the world coordinates

At this moment we must go to calculate the future positions that our mouse will have, since we are zooming, we are changing the z of the camera and therefore also the size of the canvas space (simply review the formula or explanation at the beginning of the article)

so let’s abstract more of the methods used by making the z free. The methods are

  • getABSBounds
    - we add the method getABSBoundsWithCustomZ
  • getWorldToCanvasCoordinates
    - we add the method getWorldToCanvasWithCustomZ
import * as THREE from "three";class Compass {
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private zoomOptions: {minZoom: number; maxZoom: number; step:
number};
constructor(
camera: THREE.PerspectiveCamera,
renderer: THREE.WebGLRenderer
) {
this.camera = camera;
this.renderer = renderer;
this.zoomOptions = {
maxZoom: 2,
minZoom: 10000,
step: 0.06,
};
this.setAllEvents();
}

setAllEvents = () => {
this.setZoomHandler();
}

setZoomHandler = () => {
this.renderer.domElement.onwheel = (event: WheelEvent) => {
event.preventDefault();
this.zoom(event);
};
};
getWorldToCanvasPercentCoordinates = (wx: number, wy: number) => {
const canvas = this.renderer.domElement;
const percentX = (wx / canvas.clientWidth) * 2 - 1;
const percentY = -(wy / canvas.clientHeight) * 2 + 1;
return { percentX, percentY };
};
getABSBounds = () => {
return this.getABSBoundsWithCustomZ(this.camera.position.z);
};
getWorldToCanvasCoordinates = (wx: number, wy: number) => {
const { z } = this.camera.position;
return this.getWorldToCanvasWithCustomZ(wx, wy, z);
};
private getWorldToCanvasWithCustomZ = (
wx: number, wy: number, cz: number
) => {
const { percentX, percentY } =
this.getWorldToCanvasPercentCoordinates(wx, wy);
const { x, y } = this.camera.position;
const { width, height } = this.getABSBoundsWithCustomZ(cz);
const retX = x + (width / 2) * percentX;
const retY = y + (height / 2) * percentY;
return { x: retX, y: retY };
};
private getABSBoundsWithCustomZ = (z: number) => {
const fov = this.camera.fov;
const aspect = this.camera.aspect;
const halfHeight = Math.tan((fov * Math.PI) / 180 / 2) * z;
const halfWidth = halfHeight * aspect;
return { width: halfWidth * 2, height: halfHeight * 2 };
};
private zoom = (event: WheelEvent) => {
const { clientX, clientY, deltaY } = event;
const { step, minZoom, maxZoom } = this.zoomOptions;
const { z: zp } = this.camera.position;
let nz = zp;
nz += deltaY * step;
if (nz < maxZoom) {
nz = maxZoom;
} else if (nz > minZoom) {
nz = minZoom;
}
this.camera.position.setZ(nz);
};
}export default Compass;

Finally, let’s update the x and y position of the camera.

private zoom = (event: WheelEvent) => {
const { clientX, clientY, deltaY } = event;
const { step, minZoom, maxZoom } = this.zoomOptions;
const { z: zp } = this.camera.position;
let nz = zp;
nz += deltaY * step;
if (nz < maxZoom) {
nz = maxZoom;
} else if (nz > minZoom) {
nz = minZoom;
}
const { x, y } = this.getWorldToCanvasCoordinates(clientX,
clientY);
const { x: futureX, y: futureY } =
this.getWorldToCanvasWithCustomZ(clientX, clientY, nz);
const offX = x - futureX;
const offY = y - futureY;
this.camera.translateX(offX);
this.camera.translateY(offY);

this.camera.position.setZ(nz);
};

The logic therefore is

  • I take the current coordinates, with the current z
  • I take the future coordinates, with the future z
  • I calculate the difference between the two positions
  • I move the x and y of the canvas to cushion the movement

This is the result so far

Panning

By pan I mean the possibility of translating the visualization using the mouse, then translating the camera. What I want to replicate is the behavior of google maps, where, using the mouse, you can navigate the map highlighting what you want to deepen.

On a theoretical level, what we have to do is calculate the Δ displacement of the mouse in the space of the canvas and then move the center of the camera by that Δ.

Steps

  • on click save the initial position
  • on drag, I calculate the difference between the old position and the new one
  • I update the old position with the new one
  • I update the position of the camera

Implementation

Let’s add the method for dragging the drawing, to the compass class

setDragCanvas = () => {
this.renderer.domElement.onmousedown = (event) => {
........
........
};
window.onmousemove = (event) => {
........
........
};
this.renderer.domElement.onmouseup = (event) => {
........
........
};
};

As you can see we have 3 events to synchronize with

  • onmousedown:
    -
    we need it to save the first position to be compared
    - we need it to notify that we are dragging the drawing
  • onmousemove:
    - it gives us the updated position with which to calculate the displacement
  • onmouseup:
    - notifies that the drag operation is finished

First we set an environment variable that manages the state of the drag

// tipo
private draggingObj: { isItDragging: boolean; positions: { x: number; y: number }; };
// init
this.draggingObj = { isItDragging: false, positions: { x: 0, y: 0 } };

Now we are going to implement the various functions

onmousedown

this.renderer.domElement.onmousedown = (event) => {
const { clientX, clientY } = event;
this.draggingObj.isItDragging = true;
const { x, y } = this.getWorldToCanvasABSCoordinates(clientX,
clientY);
this.draggingObj.positions = { x, y };
};

I take the world coordinates, translate them into the absolute space of the canvas imposed on them in the state and notify that I am dragging.

For simplicity I use the ABS coordinates, where to create that method (getWorldToCanvasABSCoordinates) the procedure is very similar to the real one of the canvas, you can still see the implementation in the final example at the end of the article.

onmousemove

window.onmousemove = (event) => {
if (this.draggingObj.isItDragging) {
const { clientX, clientY } = event;
const { x, y } = this.draggingObj.positions;
const { x: newX, y: newY } =
this.getWorldToCanvasABSCoordinates(clientX, clientY);
this.draggingObj.positions = { x: newX, y: newY };
this.camera.translateX(x - newX);
this.camera.translateY(y - newY);
}
};

The peculiarity here lies in the fact that we are using window because otherwise there would be an unwanted effect when you exit the canvas, in this way the drag remains linear even in this last case.

What is done is very simple; first we make sure to drag only when the drag is active, then the current position is taken and the camera is updated with the difference of the two positions.

onmouseup

this.renderer.domElement.onmouseup = (event) => {
this.draggingObj.isItDragging = false;
};

It just notifies that the drag is done

Final result

Appendix

Here are proposed “definitions” in my way to explain some concepts, but this does not want to stop your curiosity, so if you want to learn more you can read here

FOV: Field Of View

Figure 10

Represents the angle of view on the y axis, which affects the x axis according to the aspect that our canvas has

ASPECT: Aspect of form is therefore the relationship between the width of the canvas and its height => canvasWidth / canvasHeight

Bibliography

[Yi Soo Yi et al., 07] Kitzinger, Jenny. “Qualitative research: introducing focus groups.” Bmj 311.7000 (1995): 299–302.

--

--