Every drawing, whiteboard, or diagram app out there implemented a way to have an "Infinite canvas". These apps simulate an infinite amount of space by using math to update the coordinates of the canvas content.
I recently worked on a project that required exactly this functionality. I made some research and implemented an infinite canvas using javascript (typescript ππΌββοΈ) and simple math.
Here is how it works, and the code that makes it work π
Infinite <canvas>
The strategy to make the canvas "infinite" is to map its real size and coordinates on the screen to a new "unlimited" set of coordinates.
By default we set the canvas width
and height
to match the available size of the screen:
this.canvas.width = document.body.clientWidth;
this.canvas.height = document.body.clientHeight;
The real coordinates of the canvas start at 0,0
in the top-left corner. The x axis expands from the top-left to the top-right corner, while the y axis goes from the top-left to the bottom-left.
We are going to map these coordinates to a new set of coordinates to simulate an infinite canvas.
The new coordinates are defined by three parameters:
scale
: used to zoom in and out the overall size of the canvasoffsetX
: used to move the coordinates on the x axisoffsetY
: used to move the coordinates on the y axis
Convert coordinates
We define 4 helper functions that allow to convert from real to virtual coordinates and vice versa.
Converting from the real coordinates to the new virtual system requires to shift using offsetX
(horizontal) and offsetY
(vertical) and change the size using scale
(zoom):
toVirtualX(xReal: number): number {
return (xReal + this.#offsetX) * this.#scale;
}
toVirtualY(yReal: number): number {
return (yReal + this.#offsetY) * this.#scale;
}
Converting back to the real coordinates is the inverse function:
toRealX(xVirtual: number): number {
return xVirtual / this.#scale - this.#offsetX;
}
toRealY(yVirtual: number): number {
return yVirtual / this.#scale - this.#offsetY;
}
We also define 2 more functions to compute the virtual height and width of the canvas based on scale
:
virtualHeight(): number {
return this.canvas.clientHeight / this.#scale;
}
virtualWidth(): number {
return this.canvas.clientWidth / this.#scale;
}
canvas.clientHeight
andcanvas.clientWidth
are the real canvas height and width on the screen.
Double touch to zoom and pan
Both the zooming and panning interactions require touching the screen with 2 fingers.
The input parameters required are the position on the screen of the current and "previous" touching event. Since we are listening for a double touch with 2 fingers, we are going to have 4 values in total:
prevTouch0
(prevTouch0X
,prevTouch0Y
): previous touch position of the first fingerprevTouch1
(prevTouch1X
,prevTouch1Y
): previous touch position of the second fingertouch0
(touch0X
,touch0Y
): current touch position of the first fingertouch1
(touch1X
,touch1Y
): current touch position of the second finger
We are going to add 2 event listeners on the <canvas>
element:
touchstart
: Register "previous" touchtouchmove
: Update the current touch position
We are then going to extract the touches
value from the event listener:
canvas.addEventListener("touchstart", (event) =>
this.#onTouchStart(event.touches)
);
canvas.addEventListener("touchmove", (event) =>
this.#onTouchMove(event.touches)
);
onTouchStart
updates the value of the previous touch, checking also if we are touching with 2 fingers ("double"
). onTouchStart
then calls onTouchMove
to start the interaction directly on the first click:
#onTouchStart(touches: TouchList) {
if (touches.length == 1) {
this.#touchMode = "single";
} else if (touches.length >= 2) {
this.#touchMode = "double";
}
this.#prevTouch[0] = touches[0];
this.#prevTouch[1] = touches[1];
this.#onTouchMove(touches);
}
onTouchMove
is responsible to get the required input parameters and call zoom
and pan
:
#onTouchMove(touches: TouchList) {
const touch0X = touches[0].pageX;
const touch0Y = touches[0].pageY;
const prevTouch0X = this.#prevTouch[0].pageX;
const prevTouch0Y = this.#prevTouch[0].pageY;
if (this.#touchMode === "single") {
// Single touch (drawing π¨)
} else if (this.#touchMode === "double") {
const touch1X = touches[1].pageX;
const touch1Y = touches[1].pageY;
const prevTouch1X = this.#prevTouch[1].pageX;
const prevTouch1Y = this.#prevTouch[1].pageY;
/* Call here `zoom`, `pan`, and `draw` (read next paragraphs π) */
}
/* Update previous touches for next interaction */
this.#prevTouch[0] = touches[0];
this.#prevTouch[1] = touches[1];
}
Zoom in and out
The canvas allows to zoom in and out using 2 fingers on mobile (pinch-zoom).
Zooming in and out requires doing some math to compute how much to change the scale
value.
The scale
amount consists in the relative distance between the current and previous touch.
The distance between two points in space can be computed using the Pythagorean theorem:
const distancePreviousTouches = Math.sqrt(
Math.pow(prevTouch0X - prevTouch1X, 2) + Math.pow(prevTouch0Y - prevTouch1Y, 2)
);
const distanceCurrentTouches = Math.sqrt(
Math.pow(touch0X - touch1X, 2) + Math.pow(touch0Y - touch1Y, 2)
);
We can now compare the relative change in these two values to get the zoomAmount
:
const zoomAmount = distanceCurrentTouches / distancePreviousTouches;
Finally, we update the scale
value by multiplying by zoomAmount
:
this.#scale *= zoomAmount;
Pan (moving the canvas)
The second interaction to allow an infinite canvas is panning: moving the canvas in an horizontal or vertical direction to simulate an infinite amount of space.
Panning requires the same inputs as before, since we are going to allow this interaction only when 2 fingers are touching the screen (a single touch will be reserved for drawing and clicks):
prevTouch0
(prevTouch0X
,prevTouch0Y
): previous touch position of the first fingerprevTouch1
(prevTouch1X
,prevTouch1Y
): previous touch position of the second fingertouch0
(touch0X
,touch0Y
): current touch position of the first fingertouch1
(touch1X
,touch1Y
): current touch position of the second finger
Panning requires to compute the middle point between the current and previous touch. The middle point is the difference between the two positions divided by 2:
const prevMidX = (prevTouch0X + prevTouch1X) / 2;
const prevMidY = (prevTouch0Y + prevTouch1Y) / 2;
const midX = (touch0X + touch1X) / 2;
const midY = (touch0Y + touch1Y) / 2;
The difference between the middle points is the distance on the vertical (Y) and horizontal (X) axis that the fingers moved between the two touches:
const panX = midX - prevMidX;
const panY = midY - prevMidY;
Now we can update offsetX
and offsetY
by adding these 2 values scaled based on scale
:
this.#offsetX += panX / this.#scale;
this.#offsetY += panY / this.#scale;
Dividing by
scale
makes the change in the offset relative to the current zoom amount: if the zoom is high, the offset distance should be reduced (and vice versa).
Combining panning and zooming in one action
We must consider also another offset that happens when combining the zooming and panning action: the change in scale
must be considered when updating offsetX
and offsetY
.
We start by defining a new value scaleAmount
:
const scaleAmount = 1 - zoomAmount;
From here we compute the zoomRatio
and the units zoomed from each edge of the screen:
const zoomRatioX = midX / this.canvas.clientWidth;
const zoomRatioY = midY / this.canvas.clientHeight;
// Amount zoomed from each edge of the screen
const unitsZoomedX = this.virtualWidth() * scaleAmount;
const unitsZoomedY = this.virtualHeight() * scaleAmount;
We then use these values to adjust the update on offsetX
and offsetY
:
const unitsAddLeft = unitsZoomedX * zoomRatioX;
const unitsAddTop = unitsZoomedY * zoomRatioY;
this.#offsetX += unitsAddLeft;
this.#offsetY += unitsAddTop;
There is more π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
Drawing a grid
Now we can use scale
, offsetX
, and offsetY
to draw anything on an infinite canvas.
In this example, we are going to add an infinite grid to the canvas.
A grid consist in a series of horizontal and vertical lines with a constant distance between each line.
The first parameter required is the distance between each line (cellSize
). We allow to pass this value as input when drawing the grid.
The
cellSize
is the size of a cell in the grid whenscale = 1
. As we zoom in and out by changing thescale
the cell size will change as well.
All we need to do now is to loop over the full width and height of the screen and draw a line at constant interval based on cellSize
:
/* Full height and width (canvas size) */
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
/* Style of the grid line */
this.context.strokeStyle = "rgb(229,231,235)";
this.context.lineWidth = 1;
this.context.beginPath();
/* Vertical lines spanning the full width */
for (
/* Start the first line based on offsetX and scale */
let x = (this.#offsetX % this.cellSize) * this.#scale;
x <= width;
/* Cell size based on scale amount */
x += this.cellSize * this.#scale
) {
this.context.moveTo(x, 0);
this.context.lineTo(x, height);
}
/* Horizontal lines spanning the full height */
for (
/* Start the first line based on offsetY and scale */
let y = (this.#offsetY % this.cellSize) * this.#scale;
y <= height;
/* Cell size based on scale amount */
y += this.cellSize * this.#scale
) {
this.context.moveTo(0, y);
this.context.lineTo(width, y);
}
/* Draw the lines (path) on the canvas */
this.context.stroke();
Rendering cycle
We define a new draw()
function to update the drawings in the canvas:
draw() {
this.canvas.width = document.body.clientWidth;
this.canvas.height = document.body.clientHeight;
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.#drawGrid();
// Define and execute here other drawings π¨
}
It is important to draw clearRect
to remove the previous drawings and update with the new ones. We need to call draw()
every time we change scale
, offsetX
, offsetY
, or every time the user draws something on the canvas.