So I have created an html canvas with the illusion of an infinite grid. My goal was to make it as efficient as possible, drawing only what is visible and simulating all the effects by doing the math, instead of for example drawing the grid 3-times as wide and high to make it "infinite". I implemented it by storing the x- and y-offset of the grid. When the mouse moves, the offset is being increased by the moved distance, and then clamped to the cell size. This way, when the moved distance is bigger than the size of one cell, the offset starts again at 0, because only the "overlapping distance" needs to be drawn. This way I can create the illusion of an infinite grid without actually having to worry about world coordinates etc. The snippet below is a working version of this:
let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");
let width = 200;
let height = 200;
let dpi = 4;
let cellSize = 10;
let pressed = false;
canvas.height = height * dpi;
canvas.width = width * dpi;
canvas.style.height = height + "px";
canvas.style.width = width + "px";
canvas.addEventListener("mousedown", (e) => mousedown(e));
canvas.addEventListener("mouseup", (e) => mouseup(e));
canvas.addEventListener("mousemove", (e) => mousemove(e));
let offset = {x: 0, y: 0};
draw();
function draw() {
ctx.save();
ctx.scale(dpi, dpi);
ctx.translate(-0.5, -0.5);
ctx.lineWidth = 1;
ctx.strokeStyle = "silver";
ctx.beginPath();
for (let x = offset.x; x < width; x += cellSize) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = offset.y; y < height; y += cellSize) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function mousedown(e) {
pressed = true;
}
function mouseup(e) {
pressed = false;
}
function mousemove(e) {
if (!pressed) {
return;
}
ctx.clearRect(0, 0, width * dpi, height * dpi);
offset.x += e.movementX;
offset.y += e.movementY;
let signX = offset.x > 0 ? 1 : -1;
let signY = offset.y > 0 ? 1 : -1;
offset = {
x: (Math.abs(offset.x) > cellSize)
? offset.x - Math.floor((offset.x * signX) / cellSize) * cellSize * signX
: offset.x,
y: (Math.abs(offset.y) > cellSize)
? offset.y - Math.floor((offset.y * signY) / cellSize) * cellSize * signY
: offset.y
};
draw();
}
canvas {
background-color: white;
}
<canvas></canvas>
I now wanted to implement zooming into the illusion. This could be achieved by increasing the cell size according to the zoom level. I also changed the way the grid was drawn: Instead of clamping the offset, and beginning to draw the lines at that offset, the offset is now the center of the grid (thats why its starting position is at w/2 and h/2). I then draw the lines from the offset to the left, top, bottom and right edge. This allows me, when zooming, to simply set the offset to the mouse position, and increase the cell size. This way it looks like it would zoom to the mouse position, because the cell size increases "away" from that point. This works fine and looks pretty nice so far, try it out below:
let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");
let width = 200;
let height = 200;
let dpi = 4;
let cellSize = 10;
let pressed = false;
let zoomIntensity = 0.1;
let zoom = 1;
canvas.height = height * dpi;
canvas.width = width * dpi;
canvas.style.height = height + "px";
canvas.style.width = width + "px";
canvas.addEventListener("mousedown", (e) => mousedown(e));
canvas.addEventListener("mouseup", (e) => mouseup(e));
canvas.addEventListener("mousemove", (e) => mousemove(e));
canvas.addEventListener("wheel", (e) => wheel(e));
let offset = {x: width / 2, y: height / 2};
draw();
function draw() {
ctx.save();
ctx.scale(dpi, dpi);
ctx.translate(-0.5, -0.5);
ctx.lineWidth = 1;
ctx.strokeStyle = "silver";
ctx.beginPath();
for (let x = offset.x; x < width; x += cellSize * zoom) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let x = offset.x; x > 0; x -= cellSize * zoom) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = offset.y; y < height; y += cellSize * zoom) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
for (let y = offset.y; y > 0; y -= cellSize * zoom) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = "red";
ctx.arc(offset.x, offset.y, 4, 0, 2*Math.PI);
ctx.fill();
ctx.restore();
}
function mousedown(e) {
pressed = true;
}
function mouseup(e) {
pressed = false;
}
function mousemove(e) {
if (!pressed) {
return;
}
offset.x += e.movementX;
offset.y += e.movementY;
let signX = offset.x > 0 ? 1 : -1;
let signY = offset.y > 0 ? 1 : -1;
/*offset = {
x: (Math.abs(offset.x) > cellSize)
? offset.x - Math.floor((offset.x * signX) / cellSize) * cellSize * signX
: offset.x,
y: (Math.abs(offset.y) > cellSize)
? offset.y - Math.floor((offset.y * signY) / cellSize) * cellSize * signY
: offset.y
};*/
update();
}
function update() {
ctx.clearRect(0, 0, width * dpi, height * dpi);
draw();
}
function wheel(e) {
offset = {x: e.offsetX, y: e.offsetY};
zoom += ((e.deltaY > 0) ? -1 : 1) * zoomIntensity;
update();
}
canvas {
background-color: white;
}
<p>Scroll with mouse wheel<p>
<canvas></canvas>
However, as you might have noticed, when changing the mouse position while zooming, the grid looks like it jumps to that position. That is logical, because the new center of the grid is exactly at the mouse position - regardless of the distance of the mouse to the nearest cell. This way, when trying to zoom in on the center of a cell, the new grid creates a line exactly at that center, making it look like it jumped. I tried to store the offset of the mouse position to the nearest cell border and drawing everything shifted by that offset, but I couldnt get it to work. I would need to know when a new wheel event is initiated (like on mousedown) and then store the offset to the nearest cells borders, and draw everything shifted by those offsets * zoom, until the zooming ended. I am having trouble implementing something like that, because there are no inbuilt listener functions for the wheel besides wheel
, and using something different like keyboard etc. isnt an option here. It is still my goal to make that illusion as efficient as it can be, trying to avoid ctx.scale()
and ctx.translate
and rather calculate the coords myself.
I was able to solve it on my own after many hours of trying different mathematical approches. At the end of this answer you will find a working snippet that simulates an infinite, pan- and zoomable grid. Because Im doing all the maths myself and draw only whats visible, the illusion of the infinite grid is really fast and efficient. You might notice that the lines are sometimes blurry when zooming in, this could be solved by rounding the numbers at which the lines are drawn to integers, however then you will notice small "jumps" while zooming in, because the lines are slighty shifted. I will now give my best to try to explain process how I achieved the illusion and explain the mathematical procedures.
Quick notes for the snippet:
zoomPoint
(red point) is the point around the grid should be zoomedrx
and ry
(blue lines) are the offset from the zoomPoint to the right or bottom border of the nearest celltop
, left
, bottom
and right
variables are the coordinates, at which the borders of the cell around the zoomPoint should be drawnHow does the zooming work?
zoomPoint
in lastZoomPoint
zoomPoint
to new position of mouselastScale
scale
by adding scaleStep * amt
where amt is -1
or 1
, depending on the wheels scrolling direction (deltaY
)rx
and ry
(the distances from the new zoomPoint to the nearest vertical or horizontal line). it is important to use lastZoomPoint
and lastScale
here! Here is a piece of my notes to better understand exactly whats going on(needed for the following explanation):
picturerx
and ry
with scale
, and draw the new gridSo we want to calculate the new rx
(and ry
, but once we have a formula for rx
we can use that to calculate ry
).
P2 (the green point) is our lastZoomPoint
. We need the new rx
, in the picture its called rneu. To get that, we want to subtract the yellow distance from Pneu. But actually, we can make this simpler. We only need a point that we know is perfectly on any of the vertical lines, lets call it Pv. Because rneu is the distance of Pneu to the next vertical line on its left, we need a point Pnearest, exactly on that vertical line. We can get that Point by just moving Pv by the size of one cell times the amount of cells between the two points. Well, we know the size of one cell (cellSize * lastScale
), we now need the number of cells in between Pv and Pnearest. We can get that number by calculating the x-distance of these two points and divide it by the size of one cell. Lets use Math.floor
to get the number of whole cells in between. Multiply that number by the size of one cell and add it to Pv and voilà, we get Pnearest. Lets write that down mathematically:
let Rneu = Pneu - Pnearest
let Pnearest = Pv + NWholeCells * scaledCellSize
let NWholeCells = Math.floor((Pneu - Pv) / (scaledCellSize))
let scaledCellSize = lastScale * cellSize
Substitute everything in, we get:
let Rneu = Pneu - (Pv + Math.floor((Pneu - Pv) / (lastScale * cellSize)) * (lastScale * cellSize))
So we know Pneu, lastScale
and cellSize
. The only thing missing is Pv. Remember, this was any Point that lies perfectly on one of the vertical lines. And this is going to be straightforward: Subtract rx
(the one from the previous calculation) from P2 and we have ourselves a point that is perfectly on a vertical line!
So:
let Pv = P2 - rx
// substitue that in again
let Rneu = Pneu - ((P2 - rx) + Math.floor((Pneu - (P2 - rx)) / (lastScale * cellSize)) * (lastScale * cellSize));
Nice! Simplify that, and we get:
let Rneu = Pneu - P2 + rx - Math.floor((Pneu - P2 + rx) / (lastScale * cellSize)) * lastScale * cellSize);
In the snippet Pneu - P2 + rx
is called dx
.
So, now we have the distance from our new zoomPoint to the nearest vertical line. This distance however is obviously scaled by lastScale
, so we need to divide rx
by lastScale
(this will become cleared in further explanation)
Now we have a "cleansed" rx
. This "cleansed" value is the distance Pneu to the nearest cell border, if the scale was equal to 1. This is our "initial state". From that (this is now in calculateDrawingPositions()
), to make it appear as if we were zooming in on Pneu, we only need to multiple the "cleansed" rx
by the new scale (this time not lastScale
but scale
!). Now we start drawing the vertical lines of our new grid at Pneu - rx * scale
, and decrease that x by cellSize * scale
, until x reached 0. We now have the vertical lines on the left of our Pneu. Do the same for the horizontal lines above Pneu, and the starting points for the lines on the right or below are easily calculated by adding cellSize * scale
to the left or top drawing position.
Puh, done! Thats it! I hope I didnt miss something in my explanation and everything is correct. It took me long to come up with that solution, I hope I explained it in a way it can be easily understood, however I dont know if thats even possible, for me this whole task was pretty complex.
let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");
let width = 200;
let height = 200;
let dpi = 4;
let cellSize = 10;
let backgroundColor = "white";
let lineColor = "silver";
let pressed = false;
let scaleStep = 0.1;
let scale = 1;
let lastScale = scale;
let maxScale = 10;
let minScale = 0.1;
let zoomPoint = {
x: 0,
y: 0
};
let lastZoomPoint = zoomPoint;
let rx = 0,
ry = 0;
let left = 0,
right = 0,
_top = 0,
bottom = 0;
resizeCanvas();
addEventListeners();
calculate();
draw();
function resizeCanvas() {
canvas.height = height * dpi;
canvas.width = width * dpi;
canvas.style.height = height + "px";
canvas.style.width = width + "px";
}
function addEventListeners() {
canvas.addEventListener("mousedown", (e) => mousedown(e));
canvas.addEventListener("mouseup", (e) => mouseup(e));
canvas.addEventListener("mousemove", (e) => mousemove(e));
canvas.addEventListener("wheel", (e) => wheel(e));
}
function calculate() {
calculateDistancesToCellBorders();
calculateDrawingPositions();
}
function calculateDistancesToCellBorders() {
let dx = zoomPoint.x - lastZoomPoint.x + rx * lastScale;
rx = dx - Math.floor(dx / (lastScale * cellSize)) * lastScale * cellSize;
rx /= lastScale;
let dy = zoomPoint.y - lastZoomPoint.y + ry * lastScale;
ry = dy - Math.floor(dy / (lastScale * cellSize)) * lastScale * cellSize;
ry /= lastScale;
}
function calculateDrawingPositions() {
let scaledCellSize = cellSize * scale;
left = zoomPoint.x - rx * scale;
right = left + scaledCellSize;
_top = zoomPoint.y - ry * scale;
bottom = _top + scaledCellSize;
}
function draw() {
ctx.save();
ctx.scale(dpi, dpi);
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
ctx.lineWidth = 1;
ctx.strokeStyle = lineColor;
ctx.translate(-0.5, -0.5);
ctx.beginPath();
let scaledCellSize = cellSize * scale;
for (let x = left; x > 0; x -= scaledCellSize) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let x = right; x < width; x += scaledCellSize) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = _top; y > 0; y -= scaledCellSize) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
for (let y = bottom; y < height; y += scaledCellSize) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.stroke();
/* Only for understanding */
ctx.strokeStyle = "blue";
ctx.beginPath();
ctx.moveTo(zoomPoint.x, zoomPoint.y);
ctx.lineTo(left, zoomPoint.y);
ctx.moveTo(zoomPoint.x, zoomPoint.y);
ctx.lineTo(zoomPoint.x, _top);
ctx.stroke();
ctx.fillStyle = "red";
ctx.beginPath();
ctx.arc(zoomPoint.x, zoomPoint.y, 2, 0, 2*Math.PI);
ctx.fill();
/* -----------------------*/
ctx.restore();
}
function update() {
ctx.clearRect(0, 0, width * dpi, height * dpi);
draw();
}
function move(dx, dy) {
zoomPoint.x += dx;
zoomPoint.y += dy;
}
function zoom(amt, point) {
lastScale = scale;
scale += amt * scaleStep;
if (scale < minScale) {
scale = minScale;
}
if (scale > maxScale) {
scale = maxScale;
}
lastZoomPoint = zoomPoint;
zoomPoint = point;
}
function wheel(e) {
zoom(e.deltaY > 0 ? -1 : 1, {
x: e.offsetX,
y: e.offsetY
});
calculate();
update();
}
function mousedown(e) {
pressed = true;
}
function mouseup(e) {
pressed = false;
}
function mousemove(e) {
if (!pressed) {
return;
}
move(e.movementX, e.movementY);
// do not recalculate the distances again, this wil lead to wronrg drawing
calculateDrawingPositions();
update();
}
canvas {
border: 1px solid black;
}
<p>Zoom with wheel, move by dragging</p>
<canvas></canvas>