Skip to content
Advertisement

Javascript pseudo-zoom around mouse position in canvas

I would like to “zoom” (mouse wheel) a grid I have, around the mouse position in canvas (like Desmos does). By zooming, I mean redrawing the lines inside the canvas to look like a zoom effect, NOT performing an actual zoom. And I would like to only use vanilla javascript and no libraries (so that I can learn).

At this point, I set up a very basic magnification effect that only multiplies the distance between the gridlines, based on the mouse wheel values.

////////////////////////////////////////////////////////////////////////////////

    // User contants

    const canvasWidth      = 400;
    const canvasHeight     = 200;
    const canvasBackground = '#282c34';

    const gridCellColor  = "#777";
    const gridBlockColor = "#505050";
    const axisColor      = "white";

    // Internal constants

    const canvas       = document.getElementById('canvas');
    const context      = canvas.getContext('2d', { alpha: false });
    const bodyToCanvas = 8;

    ////////////////////////////////////////////////////////////////////////////////

    // User variables

    let cellSize  = 10;
    let cellBlock = 5;
    let xSubdivs  = 40
    let ySubdivs  = 20

    // Internal variables

    let grid        = '';
    let zoom        = 0;
    let xAxisOffset = xSubdivs/2;
    let yAxisOffset = ySubdivs/2;
    let mousePosX   = 0;
    let mousePosY   = 0;

    ////////////////////////////////////////////////////////////////////////////////

    // Classes

    class Grid{
        constructor() {
            this.width     = canvasWidth,
            this.height    = canvasHeight,
            this.cellSize  = cellSize,
            this.cellBlock = cellBlock,
            this.xSubdivs  = xSubdivs,
            this.ySubdivs  = ySubdivs
        }

        draw(){
            // Show canvas
            context.fillStyle = canvasBackground;
            context.fillRect(-this.width/2, -this.height/2, this.width, this.height);

            // Horizontal lines
            this.xSubdivs = Math.floor(this.height / this.cellSize);
            for (let i = 0; i <= this.xSubdivs; i++) {this.setHorizontalLines(i);}

            // Vertical lines
            this.ySubdivs   = Math.floor(this.width  / this.cellSize);
            for (let i = 0; i <= this.ySubdivs; i++) {this.setVerticalLines(i)  ;}

             // Axis
            this.setAxis();
        }

        setHorizontalLines(i) {
            // Style
            context.lineWidth = 0.5;

            if (i % this.cellBlock == 0) {
                // light lines
                context.strokeStyle = gridCellColor;
            }
            else{
                // Dark lines
                context.strokeStyle = gridBlockColor;
            }

            //Draw lines
            context.beginPath();
            context.moveTo(-this.width/2, (this.cellSize * i) - this.height/2);
            context.lineTo( this.width/2, (this.cellSize * i) - this.height/2);
            context.stroke();
            context.closePath();
        }

        setVerticalLines(i) {
            // Style
            context.lineWidth = 0.5;

            if (i % cellBlock == 0) {
                // Light lines
                context.strokeStyle = gridCellColor;
            }
            else {
                // Dark lines
                context.strokeStyle = gridBlockColor;
            }

            //Draw lines
            context.beginPath();
            context.moveTo((this.cellSize * i) - this.width/2, -this.height/2);
            context.lineTo((this.cellSize * i) - this.width/2,  this.height/2);
            context.stroke();
            context.closePath();
        }
        
        // Axis are separated from the line loops so that they remain on 
        // top of them (cosmetic measure)

        setAxis(){
            // Style x Axis
            context.lineWidth = 1.5;
            context.strokeStyle = axisColor;

            // Draw x Axis 
            context.beginPath();
            context.moveTo(-this.width/2, (this.cellSize * yAxisOffset) - this.height/2);
            context.lineTo( this.width/2, (this.cellSize * yAxisOffset) - this.height/2);
            context.stroke();
            context.closePath();

             // Style y axis
            context.lineWidth = 1.5;
            context.strokeStyle = axisColor;

            // Draw y axis
            context.beginPath();
            context.moveTo((this.cellSize * xAxisOffset ) - this.width/2, -this.height/2);
            context.lineTo((this.cellSize * xAxisOffset ) - this.width/2,  this.height/2);
            context.stroke();
            context.closePath();
        }
    }
    ////////////////////////////////////////////////////////////////////////////////

    // Functions

    function init() {
        // Set up canvas
        if (window.devicePixelRatio > 1) {
            canvas.width = canvasWidth * window.devicePixelRatio;
            canvas.height = canvasHeight * window.devicePixelRatio;
            context.scale(window.devicePixelRatio, window.devicePixelRatio);
        }
        else {
            canvas.width = canvasWidth;
            canvas.height = canvasHeight;
        }
        canvas.style.width = canvasWidth + "px";
        canvas.style.height = canvasHeight + "px";

        // Initialize coordinates in the middle of the canvas
        context.translate(canvasWidth/2,canvasHeight/2)

        // Setup the grid
        grid = new Grid(); 

        // Display the grid
        grid.draw();
    }

    function setZoom(){
        grid.cellSize = grid.cellSize + zoom;
        grid.draw();
    }

    ////////////////////////////////////////////////////////////////////////////////

    //Launch the page

    init();

    ////////////////////////////////////////////////////////////////////////////////

    // Update the page on resize

    window.addEventListener("resize", init);

    // Zoom the canvas with mouse wheel 

    canvas.addEventListener('mousewheel', function (e) { 
        e.preventDefault();
        e.stopPropagation();
        zoom = e.wheelDelta/120;
        requestAnimationFrame(setZoom);
    })

    //  Get mouse coordinates on mouse move.

    canvas.addEventListener('mousemove', function (e) {  
        e.preventDefault();
        e.stopPropagation();

        mousePosX = parseInt(e.clientX)-bodyToCanvas ;
        mousePosY = parseInt(e.clientY)-bodyToCanvas ;
    })

    ////////////////////////////////////////////////////////////////////////////////
html, body
{
    background:#21252b;
    width:100%;
    height:100%;
    margin:0px;
    padding:0px;
    overflow: hidden;
}

span{
  color:white;
  font-family: arial;
  font-size: 12px;
  margin:8px;
}

#canvas{
    margin:8px;
    border: 1px solid white;
}
<span>Use mouse wheel to zoom</span>

<canvas id="canvas"></canvas>

This works fine but, as expected, it magnifies the grid from the top left corner not from the mouse position.

So, I thought about detecting the mouse position and then modifying all the “moveTo” and “lineTo” parts. The goal would be to offset the magnified grid so that everything is displaced except the 2 lines intersecting the current mouse coordinates.

For instance, it feels to me that instead of this:

context.moveTo(
    (this.cellSize * i) - this.width/2, 
    -this.height/2
);

I should have something like this:

context.moveTo(
    (this.cellSize * i) - this.width/2 + OFFSET BASED ON MOUSE COORDS, 
    -this.height/2
);

But after hours of trials and errors, I’m stuck. So, any help would be appreciated.

FYI: I already coded a functional panning system that took me days to achieve (that I stripped from the code here for clarity), so I would like to keep most of the logic I have so far, if possible. Unless my code is total nonsense or has performance issues, of course.

Thank you.

Advertisement

Answer

You’re already tracking the mouse position in pixels. If you transform the mouse position to the coordinate system of your grid, you can define which grid cell it’s hovering.

After zooming in, you can again calculate the mouse’s coordinate. When zooming in around another center point, you’ll see the coordinate shift.

You can undo that shift by translating the grid’s center in the opposite direction.

Here’s the main part of it:

function setZoom() {
    // Calculate the mouse position before applying the zoom
    // in the coordinate system of the grid
    const x1 = (mousePosX - grid.centerX) / grid.cellSize;
    const y1 = (mousePosY - grid.centerY) / grid.cellSize;
        
    // Make the zoom happen: update the cellSize
    grid.cellSize = grid.cellSize + zoom;
        
    // After zoom, you'll see the coordinates changed
    const x2 = (mousePosX - grid.centerX) / grid.cellSize;
    const y2 = (mousePosY - grid.centerY) / grid.cellSize;
        
    // Calculate the shift
    const dx = x2 - x1;
    const dy = y2 - y1;
        
    // "Undo" the shift by shifting the coordinate system's center
    grid.centerX += dx * grid.cellSize;
    grid.centerY += dy * grid.cellSize;
        
    grid.draw();
}

To make this easier to work with, I introduced a grid.centerX and .centerY. I updated your draw method to take the center in to account (and kind of butchered it along the way, but I think you’ll manage to improve that a bit).

Here’s the complete example:

////////////////////////////////////////////////////////////////////////////////

    // User contants

    const canvasWidth      = 400;
    const canvasHeight     = 200;
    const canvasBackground = '#282c34';

    const gridCellColor  = "#777";
    const gridBlockColor = "#505050";
    const axisColor      = "white";

    // Internal constants

    const canvas       = document.getElementById('canvas');
    const context      = canvas.getContext('2d', { alpha: false });
    const bodyToCanvas = 8;

    ////////////////////////////////////////////////////////////////////////////////

    // User variables

    let cellSize  = 10;
    let cellBlock = 5;
    let xSubdivs  = 40
    let ySubdivs  = 20

    // Internal variables

    let grid        = '';
    let zoom        = 0;
    let xAxisOffset = xSubdivs/2;
    let yAxisOffset = ySubdivs/2;
    let mousePosX   = 0;
    let mousePosY   = 0;

    ////////////////////////////////////////////////////////////////////////////////

    // Classes

    class Grid{
        constructor() {
            this.width     = canvasWidth,
            this.height    = canvasHeight,
            this.cellSize  = cellSize,
            this.cellBlock = cellBlock,
            this.xSubdivs  = xSubdivs,
            this.ySubdivs  = ySubdivs,
            this.centerX   = canvasWidth / 2,
            this.centerY   = canvasHeight / 2
        }

        draw(){
            // Show canvas
            context.fillStyle = canvasBackground;
            context.fillRect(0, 0, this.width, this.height);

            // Horizontal lines
            const minIY = -Math.ceil(this.centerY / this.cellSize);
            const maxIY = Math.ceil((this.height - this.centerY) / this.cellSize);
            for (let i = minIY; i <= maxIY; i++) {this.setHorizontalLines(i);}

            // Vertical lines
            const minIX = -Math.ceil(this.centerX / this.cellSize);
            const maxIX = Math.ceil((this.width - this.centerX) / this.cellSize);
            for (let i = minIX; i <= maxIX; i++) {this.setVerticalLines(i)  ;}
            
            this.setVerticalLines(0);
            this.setHorizontalLines(0);

        }
        
        setLineStyle(i) {
          if (i === 0) {
              context.lineWidth = 1.5;
              context.strokeStyle = axisColor;
          } else if (i % cellBlock == 0) {
              // Light lines
              context.lineWidth = 0.5;
              context.strokeStyle = gridCellColor;
          } else {
              // Dark lines
              context.lineWidth = 0.5;
              context.strokeStyle = gridBlockColor;
          }
        }

        setHorizontalLines(i) {
            // Style
            this.setLineStyle(i);

            //Draw lines
            const y = this.centerY + this.cellSize * i;
            context.beginPath();
            context.moveTo(0, y);
            context.lineTo(this.width, y);
            context.stroke();
            context.closePath();
        }

        setVerticalLines(i) {
            // Style
            this.setLineStyle(i);

            //Draw lines
            const x = this.centerX + this.cellSize * i; 
            context.beginPath();
            context.moveTo(x, 0);
            context.lineTo(x, this.height);
            context.stroke();
            context.closePath();
        }
    }
    ////////////////////////////////////////////////////////////////////////////////

    // Functions

    function init() {
        // Set up canvas
        if (window.devicePixelRatio > 1) {
            canvas.width = canvasWidth * window.devicePixelRatio;
            canvas.height = canvasHeight * window.devicePixelRatio;
            context.scale(window.devicePixelRatio, window.devicePixelRatio);
        }
        else {
            canvas.width = canvasWidth;
            canvas.height = canvasHeight;
        }
        canvas.style.width = canvasWidth + "px";
        canvas.style.height = canvasHeight + "px";

        // Setup the grid
        grid = new Grid(); 

        // Display the grid
        grid.draw();
    }

    function setZoom() {
        // Calculate the mouse position before applying the zoom
        // in the coordinate system of the grid
        const x1 = (mousePosX - grid.centerX) / grid.cellSize;
        const y1 = (mousePosY - grid.centerY) / grid.cellSize;
        
        grid.cellSize = grid.cellSize + zoom;
        
        // After zoom, you'll see the coordinates changed
        const x2 = (mousePosX - grid.centerX) / grid.cellSize;
        const y2 = (mousePosY - grid.centerY) / grid.cellSize;
        
        // Calculate the shift
        const dx = x2 - x1;
        const dy = y2 - y1;
        
        // "Undo" the shift by shifting the coordinate system's center
        grid.centerX += dx * grid.cellSize;
        grid.centerY += dy * grid.cellSize;
        
        grid.draw();
    }

    ////////////////////////////////////////////////////////////////////////////////

    //Launch the page

    init();

    ////////////////////////////////////////////////////////////////////////////////

    // Update the page on resize

    window.addEventListener("resize", init);

    // Zoom the canvas with mouse wheel 

    canvas.addEventListener('mousewheel', function (e) { 
        e.preventDefault();
        e.stopPropagation();
        zoom = e.wheelDelta/120;
        requestAnimationFrame(setZoom);
    })

    //  Get mouse coordinates on mouse move.

    canvas.addEventListener('mousemove', function (e) {  
        e.preventDefault();
        e.stopPropagation();

        mousePosX = parseInt(e.clientX)-bodyToCanvas ;
        mousePosY = parseInt(e.clientY)-bodyToCanvas ;
    })

    ////////////////////////////////////////////////////////////////////////////////
html, body
{
    background:#21252b;
    width:100%;
    height:100%;
    margin:0px;
    padding:0px;
    overflow: hidden;
}

span{
  color:white;
  font-family: arial;
  font-size: 12px;
  margin:8px;
}

#canvas{
    margin:8px;
    border: 1px solid white;
}
<canvas id="canvas"></canvas>
User contributions licensed under: CC BY-SA
2 People found this is helpful
Advertisement