I'm trying change the zoom origin to the mouse cursor instead of the default top left corner and I can't use ctx.translate with ctx.scale as the grid buffer needs to be redrawn and can't be scaled (one line must always be 1px wide). The grid can be scaled and moved, it's just the origin that isn't correct.
I don't understand how to calculate the new x and the new y coordinates of the grid after zoom.
The important piece of code and what I already tried are commented in the Camera class.
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// utils //
function getCursorPos(evt) {
const rect = canvas.getBoundingClientRect();
return {
x: Math.floor(((evt.clientX - rect.left) / (rect.right - rect.left)) * canvas.offsetWidth),
y: Math.floor(((evt.clientY - rect.top) / (rect.bottom - rect.top)) * canvas.offsetHeight),
};
}
//////////
const scene = {
renderer: canvas,
context: ctx,
width: 1200,
height: 1000,
cellSize: 30,
render: function (buffer, x, y) {
this.context.clearRect(0, 0, this.renderer.width, this.renderer.height);
this.context.drawImage(buffer, x, y);
},
};
class Grid {
constructor() {
this.width = scene.width;
this.height = scene.height;
this.cellSize = scene.cellSize;
this.color = "black";
this.buffer = document.createElement("canvas");
this.buffer.width = this.width;
this.buffer.height = this.height;
}
build() {
// we don't directly make the draw calls on the main canvas (scene.renderer) ,
// instead we create a buffer (a canvas element in this case),
// which will be drawn as an image on the main canvas when we call scene.render();
const ctx = this.buffer.getContext("2d");
ctx.clearRect(0, 0, this.buffer.width, this.buffer.height);
ctx.setLineDash([2, 5]);
for (let u = 0, len = this.height; u < len; u += this.cellSize) {
ctx.beginPath();
ctx.moveTo(0.5, u + 0.5);
ctx.lineTo(0.5 + this.width, u + 0.5);
ctx.stroke();
}
for (let u = 0, len = this.width; u < len; u += this.cellSize) {
ctx.beginPath();
ctx.moveTo(u + 0.5, 0.5);
ctx.lineTo(u + 0.5, 0.5 + this.height);
ctx.stroke();
}
}
setDimensions(w, h) {
this.width = w;
this.height = h;
}
getDimensions() {
return { gw: this.width, gh: this.height };
}
setCellSize(size) {
this.cellSize = size;
}
getCellSize() {
return this.cellSize;
}
getBuffer() {
return this.buffer;
}
}
class Camera {
constructor() {
this.x = 0;
this.y = 0;
this.startDrag = null;
this.zoom = 1;
this.zoomInc = 0.05;
}
// converts screen coordinates to world coordinates
toWorld(number) {
return Math.floor(number / this.zoom);
}
toScreen(number) {
return Math.floor(number / this.zoom);
}
setStartDrag(coord) {
this.startDrag = { x: this.x + coord.x, y: this.y + coord.y };
}
isStartedDrag() {
return !!this.startDrag;
}
drag(coord) {
this.x = this.startDrag.x - coord.x;
this.y = this.startDrag.y - coord.y;
}
stopDrag() {
this.startDrag = null;
}
// the bit of code I can't figure //
setScale({ x, y, deltaY }) {
const step = deltaY > 0 ? -this.zoomInc : this.zoomInc;
this.zoom += step;
// this.x and this.y is where the grid is going to be rendered on the canvas;
// first I thought about doing it this way :
//this.x = -this.toScreen(this.toWorld(x) - x);
//this.y = -this.toScreen(this.toWorld(y) - y);
// but it only work if the grid is at x: 0 y: 0;
// after some research I tried to shift x and y relatively to the cursor world position in the grid;
//const worldPos = { x: this.toWorld(x) - this.x, y: this.toWorld(y) - this.y };
//this.x = -(this.x - worldPos.x * step);
//this.y = -(this.y - worldPos.y * step);
// if x and y aren't changed the zoom origin defaults to the current origin of the camera;
}
getZoom() {
return this.zoom;
}
}
function init() {
// initial setup //
const grid = new Grid();
const camera = new Camera();
grid.build();
const gridBuffer = grid.getBuffer();
scene.context.drawImage(gridBuffer, 0, 0);
scene.renderer.addEventListener("mousemove", (evt) => {
if (camera.isStartedDrag()) {
camera.drag(getCursorPos(evt));
scene.render(gridBuffer, -camera.x, -camera.y);
}
});
scene.renderer.addEventListener("mousedown", (evt) => {
camera.setStartDrag(getCursorPos(evt));
});
scene.renderer.addEventListener("mouseup", () => {
camera.stopDrag();
});
scene.renderer.addEventListener("wheel", (evt) => {
evt.preventDefault();
camera.setScale(evt);
const zoom = camera.getZoom();
grid.setCellSize(scene.cellSize * zoom);
grid.setDimensions(scene.width * zoom, scene.height * zoom);
// we rebuild a smaller or bigger grid according to the new zoom level;
grid.build();
const gridBuffer = grid.getBuffer();
scene.render(gridBuffer, -camera.x, -camera.y);
});
}
init();
<html lang="en">
<head>
<script defer src="main.js"></script>
</head>
<body>
<canvas id="canvas" width="800" height="600" style="border: 1px solid black"></canvas>
</body>
</html>
Here is a fiddle : https://jsbin.com/wecupoxefe/edit?html,js,output
The explanation is with the code:
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// utils //
function getCursorPos(evt) {
const rect = canvas.getBoundingClientRect();
return {
x: Math.floor(((evt.clientX - rect.left) / (rect.right - rect.left)) * canvas.offsetWidth),
y: Math.floor(((evt.clientY - rect.top) / (rect.bottom - rect.top)) * canvas.offsetHeight),
};
}
//////////
const scene = {
renderer: canvas,
context: ctx,
width: 1200,
height: 1000,
cellSize: 30,
render: function (buffer, x, y) {
this.context.clearRect(0, 0, this.renderer.width, this.renderer.height);
this.context.drawImage(buffer, x, y);
},
};
class Grid {
constructor() {
this.width = scene.width;
this.height = scene.height;
this.cellSize = scene.cellSize;
this.color = "black";
this.buffer = document.createElement("canvas");
this.buffer.width = this.width;
this.buffer.height = this.height;
}
build() {
// we don't directly make the draw calls on the main canvas (scene.renderer) ,
// instead we create a buffer (a canvas element in this case),
// which will be drawn as an image on the main canvas when we call scene.render();
const ctx = this.buffer.getContext("2d");
ctx.clearRect(0, 0, this.buffer.width, this.buffer.height);
ctx.setLineDash([2, 5]);
for (let u = 0, len = this.height; u < len; u += this.cellSize) {
ctx.beginPath();
ctx.moveTo(0.5, u + 0.5);
ctx.lineTo(0.5 + this.width, u + 0.5);
ctx.stroke();
}
for (let u = 0, len = this.width; u < len; u += this.cellSize) {
ctx.beginPath();
ctx.moveTo(u + 0.5, 0.5);
ctx.lineTo(u + 0.5, 0.5 + this.height);
ctx.stroke();
}
}
setDimensions(w, h) {
this.buffer.width = this.width = w; // GT
this.buffer.height = this.height = h; // GT
}
getDimensions() {
return { gw: this.width, gh: this.height };
}
setCellSize(size) {
this.cellSize = size;
}
getCellSize() {
return this.cellSize;
}
getBuffer() {
return this.buffer;
}
}
class Camera {
constructor() {
this.x = 0;
this.y = 0;
this.startDrag = null;
this.zoom = 1;
this.zoomInc = 0.05;
}
// converts screen coordinates to world coordinates
toWorld(number) {
return Math.floor(number / this.zoom);
}
toScreen(number) {
return Math.floor(number / this.zoom);
}
setStartDrag(coord) {
this.startDrag = { x: this.x + coord.x, y: this.y + coord.y };
}
isStartedDrag() {
return !!this.startDrag;
}
drag(coord) {
this.x = this.startDrag.x - coord.x;
this.y = this.startDrag.y - coord.y;
}
stopDrag() {
this.startDrag = null;
}
// the bit of code I can't figure //
setScale({ x, y, deltaY }) {
const step = deltaY > 0 ? -this.zoomInc : this.zoomInc;
if (this.zoom + step <= 0) return // for extra credit ;)
// Fix x,y:
x -= canvas.offsetLeft
y -= canvas.offsetTop
const zoom = this.zoom // old zoom
this.zoom += step;
/* We want in-world coordinates to remain the same:
* (x + this.x')/this.zoom = (x + this.x)/zoom
* (y + this.y')/this.zoom = (y + this.y)/zoom
* =>
*/
this.x = (x + this.x)*this.zoom/zoom - x
this.y = (y + this.y)*this.zoom/zoom - y
// this.x and this.y is where the grid is going to be rendered on the canvas;
// first I thought about doing it this way :
//this.x = -this.toScreen(this.toWorld(x) - x);
//this.y = -this.toScreen(this.toWorld(y) - y);
// but it only work if the grid is at x: 0 y: 0;
// after some research I tried to shift x and y relatively to the cursor world position in the grid;
//const worldPos = { x: this.toWorld(x) - this.x, y: this.toWorld(y) - this.y };
//this.x = -(this.x - worldPos.x * step);
//this.y = -(this.y - worldPos.y * step);
// if x and y aren't changed the zoom origin defaults to the current origin of the camera;
}
getZoom() {
return this.zoom;
}
}
function init() {
// initial setup //
const grid = new Grid();
const camera = new Camera();
grid.build();
const gridBuffer = grid.getBuffer();
scene.context.drawImage(gridBuffer, 0, 0);
scene.renderer.addEventListener("mousemove", (evt) => {
if (camera.isStartedDrag()) {
camera.drag(getCursorPos(evt));
scene.render(gridBuffer, -camera.x, -camera.y);
}
});
scene.renderer.addEventListener("mousedown", (evt) => {
camera.setStartDrag(getCursorPos(evt));
});
scene.renderer.addEventListener("mouseup", () => {
camera.stopDrag();
});
scene.renderer.addEventListener("wheel", (evt) => {
evt.preventDefault();
camera.setScale(evt);
const zoom = camera.getZoom();
grid.setCellSize(scene.cellSize * zoom);
grid.setDimensions(scene.width * zoom, scene.height * zoom);
// we rebuild a smaller or bigger grid according to the new zoom level;
grid.build();
const gridBuffer = grid.getBuffer();
scene.render(gridBuffer, -camera.x, -camera.y);
});
}
init();
<html lang="en">
<head>
<script defer src="main.js"></script>
</head>
<body>
<canvas id="canvas" width="800" height="600" style="border: 1px solid black"></canvas>
</body>
</html>