Search code examples
javascripthtmlcanvaszooming

Zoom canvas to mouse cursor without ctx.scale


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


Solution

  • 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>