Angel
Angel

Reputation: 100

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

Upvotes: 2

Views: 680

Answers (2)

greg-tumolo
greg-tumolo

Reputation: 696

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>

Upvotes: 3

Daniele Ricci
Daniele Ricci

Reputation: 15837

I recently wrote a simple tool to show charts about covid-19 data in Italy which deals exactly with your problem and I can say that there are many aspects to take care. The only difference between my chart and what you are looking for seems to be that in my chart horizontal zoom and vertical zoom are independent.

Here is the chart, so you can check if it does what you need.

If it is what you are looking for I suggest to check the SurfaceChart class which deals with zoom (based on mouse or touch position), chart drag (with touch and mouse) and with a 1px width grid. We are speaking about 300 lines of code so I'll not explain them, I suggest to take a look and if you need some clarification feel free to ask.

Upvotes: 1

Related Questions