Search code examples
javascriptif-statementzooming

Infinite grid zoom : symmetrical zoom in and out


I'm building an infinite grid zoom in vanilla javascript for learning purposes. So far so good but I have an issue though:

When I zoom in, the values on the axis as well as the gridlines update themselves when the smallest cell size is 2 times larger than its original size. This way I get an infinite zoom. Which is fine.

To get the effect to work when I zoom out, I need the gridlines and the values on the axis to update when the actual cell size is 2 times smaller than its minimum size.

This works very well as in the preview here below. But this implies that the zoom in and zoom out effects are not symmetrical.

I would like the grid lines and values to update at the exact same time/spot regardless of the direction of the zoom (in/out). See it like this: I would like the zoom out as if it was a simple rewind of the zoom in. Which it is not.

The reason I want it like that is that when I zoom in, I am assured that the minimum distance between two values will be of 100 pixels and I would like it to be the case when I zoom out too. But when I zoom out, the minimum distance between two axis values falls to 50px.

Here is what I do for the zoom in update in the "draw()" function of the Grid class:

 if (this.cellSize >= 2 * minCellSize) {}

Here is what I do for the zoom out

 else if (this.cellSize < minCellSize / 2) {}

Those are periodic events and they trigger an update of the lines and values every now and then.

To get what I want for the zoom out, in theory, I should do the exact opposite of the zoom in instruction:

else if (this.cellSize < 2 * minCellSize) {}

But in practice, this is always the case when I zoom out since the actual cell size is always smaller than 2 times its minimum value because of the zoom in condition, so the "else if" triggers the event all the time while zooming out, not periodically.

So I don't really know how to do this.

Any help would be appreciated.

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

// Constantes

const canvas      = document.getElementById('canvas');
const context     = canvas.getContext('2d', { alpha: false });
const canvasColor = '#282c34';
const axisColor   = "#ffffff";
const minCellSize = 20;


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

// Variables

let mousePosX  = 0;
let mousePosY  = 0;
let zoom       = 1;
let zoomMin    = 0;
let zoomMax    = 0;
let opacity    = 0;
let opacityMin = 0;
let opacityMax = 0;
let gen        = 1;
let ratio      = 4;


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

// Classes

class Grid {

    constructor() {

        this.width    = stdCanvasW,
        this.height   = stdCanvasH,
        this.axisPosX = stdCanvasW / 2,
        this.axisPosY = stdCanvasH / 2,
        this.cellSize = minCellSize
        
    }

    draw() {

        // Grille au zoom infini

        // Déterminer le loop des cellules et le multiplicateur/diviseur à utiliser pour les nombres selon
        // le nombre de zoom /dézoom faits.

        if (this.cellSize >= 2 * minCellSize) {

            if      (Math.abs(Math.log2(gen)) % 3 == 0) {
                this.cellSize = 20;
                gen = gen * 2;
                ratio = ratio * 2;
            }
            else if (Math.abs(Math.log2(gen)) % 3 == 1) {
                this.cellSize = 16;
                gen = gen * 2;
                if (gen > 1) { ratio = ratio * 2.5; }
                else         { ratio = ratio * 2; }
            }
            else if (Math.abs(Math.log2(gen)) % 3 == 2) {
                this.cellSize = 20;
                gen = gen * 2;
                if (gen > 1) { ratio = ratio * 2; }
                else         { ratio = ratio * 2.5; }
            }

        }

        else if (this.cellSize < minCellSize / 2) {

            if      (Math.abs(Math.log2(gen)) % 3 == 0) {

                ratio = ratio / 2;
                this.cellSize = 20;
                gen = gen / 2

            }
            else if (Math.abs(Math.log2(gen)) % 3 == 1) {

                if (gen < 1) {ratio = ratio / 2.5;}
                else         {ratio = ratio / 2;}

                this.cellSize = 16;
                gen = gen / 2;

            }
            else if (Math.abs(Math.log2(gen)) % 3 == 2) {

                if (gen < 1) {ratio = ratio / 2;}
                else         {ratio = ratio / 2.5;}

                this.cellSize = 20;
                gen = gen / 2;

            }

        }


        // Afficher la couleur de fond du canevas

        context.fillStyle = canvasColor;
        context.fillRect(0, 0, this.width, this.height);

        // Afficher les lignes

        const minDivY  = -Math.ceil(this.axisPosY / this.cellSize);
        const minDivX  = -Math.ceil(this.axisPosX / this.cellSize);
        const maxDivY  =  Math.ceil((this.height - this.axisPosY) / this.cellSize);
        const maxDivX  =  Math.ceil((this.width  - this.axisPosX) / this.cellSize);

        for (let lineV = minDivY; lineV <= maxDivY; lineV++) {
          this.setGridLines('v', lineV); this.setGridValues('v', lineV);
        }
        
        for (let lineH = minDivX; lineH <= maxDivX; lineH++) {
          this.setGridLines('h', lineH); this.setGridValues('h', lineH);
        }

        // Afficher les axes

        this.setAxis();

    }

    setLineStyle(line) {

        if (line == 'axis') {
            
            // Les axes
            context.lineWidth   = 1;
            context.strokeStyle = axisColor;

        }
        else {

            context.lineWidth = 0.5;

            if      (line % 8 == 0) {
                if      (zoom > 0) {context.strokeStyle = 'rgba(250,250,250,0.9)';}
                else if (zoom < 0) {context.strokeStyle = 'rgba(250,250,250,' + this.setOpacity((this.cellSize / minCellSize), 0.9, 0.6) + ')';}
            }
            else if (line % 4 == 0) {
                if      (zoom > 0) {context.strokeStyle = 'rgba(250,250,250,' + this.setOpacity((this.cellSize / minCellSize), 0.6, 0.9) + ')';}
                else if (zoom < 0) {context.strokeStyle = 'rgba(250,250,250,' + this.setOpacity((this.cellSize / minCellSize), 0.6, 0.2) + ')';}
            }
             else {
                if      (zoom > 0) {context.strokeStyle = 'rgba(250,250,250,' + this.setOpacity((this.cellSize / minCellSize), 0, 0.14) + ')'; }
                else if (zoom < 0) {context.strokeStyle = 'rgba(250,250,250,' + this.setOpacity((this.cellSize / minCellSize), 0.05, 0) + ')'; }
            }

        }

    }

    setGridLines(direction, line) {

        // Styler
        this.setLineStyle(line);

        if      (direction == 'v') {

            // Tracer
            const y = (this.axisPosY + this.cellSize * line);
            context.beginPath();
            context.moveTo(0, y);
            context.lineTo(this.width, y);

        }
        else if (direction == 'h') {

            // Tracer
            const x = (this.axisPosX + this.cellSize * line);
            context.beginPath();
            context.moveTo(x, 0);
            context.lineTo(x, this.height);

        }

        context.stroke();
        context.closePath();

    }

    setGridValues(direction, line) {

        // Styler
        context.font      = '12px Arial';
        context.fillStyle = '#aaaaaa';

        // Tracer
        context.beginPath();

        if      (direction == 'v' && line % 4 == 0 && line != 0) {
        
            let value         = -line / ratio;
            let valueOffset   = context.measureText(value).width + 15
            context.textAlign = 'right'

            if      (this.axisPosX >= this.width) {
                context.fillText(value, this.width - 15, this.axisPosY - line * (-this.cellSize) + 3)
            }
            else if (this.axisPosX <= valueOffset + 15) {
                context.fillText(value, valueOffset, this.axisPosY - line * (-this.cellSize) + 3)
            }
            else {
                context.fillText(value, this.axisPosX - 15, this.axisPosY + line * this.cellSize + 3)
            }
            
        }

        else if (direction == 'h' && line % 4 == 0 && line != 0) {
            
            let value         = line / ratio;
            context.textAlign = 'center'

            if      (this.axisPosY >= this.height - canvas.offsetTop) {

                context.fillText(value, this.axisPosX + line * this.cellSize, this.height - 20)

            }
            else if (this.axisPosY <= 0) {
                context.fillText(value, this.axisPosX + line * this.cellSize, 20)
            }
            else {
                context.fillText(value, this.axisPosX + line * this.cellSize, this.axisPosY + 20)
            }

        }

        context.closePath();

    }

    setAxis() {

        // Styler
        this.setLineStyle('axis');

        //Tracer 
        context.beginPath();

        // Tracer l'horizontale
        context.moveTo(0, this.axisPosY);
        context.lineTo(this.width, this.axisPosY);
        // Tracer la verticale
        context.moveTo(this.axisPosX, 0);
        context.lineTo(this.axisPosX, this.height);

        context.stroke();
        context.closePath();

        // Numéroter le point 0
        context.font      = '12px arial';
        context.fillStyle = '#aaaaaa';
        context.textAlign = 'center'
        context.beginPath();
        context.fillText(0, this.axisPosX - 15, this.axisPosY + 20)
        context.closePath();

    }

    setPan() {

        // As long as we pan, the (0;0) coordinate is not updated yet

        const beforeX  = mouseStartX / this.cellSize;
        const beforeY  = mouseStartY / this.cellSize;

        const afterX   = mousePosX / this.cellSize;
        const afterY   = mousePosY / this.cellSize;

        const deltaX   = afterX - beforeX;
        const deltaY   = afterY - beforeY;

        this.axisPosX  = lastAxisPosX;
        this.axisPosY  = lastAxisPosY;

        this.axisPosX += deltaX * this.cellSize;
        this.axisPosY += deltaY * this.cellSize;

        this.draw();

    }

    setZoom() {

        // Calculate the mouse position before applying the zoom
        // in the coordinate system of the grid
        const beforeX  = (mousePosX - this.axisPosX) / this.cellSize;
        const beforeY  = (mousePosY - this.axisPosY) / this.cellSize;

        this.cellSize  = this.cellSize + zoom;

        // After zoom, you'll see the coordinates changed
        const afterX   = (mousePosX - this.axisPosX) / this.cellSize;
        const afterY   = (mousePosY - this.axisPosY) / this.cellSize;

        // Calculate the shift
        const deltaX   = afterX - beforeX;
        const deltaY   = afterY - beforeY;

        // "Undo" the shift by shifting the coordinate system's center
        this.axisPosX += deltaX * this.cellSize;
        this.axisPosY += deltaY * this.cellSize;

        this.draw();

    }

    setOpacity(zoomLevel, val1, val2) {

        if      (zoom > 0) {
            opacityMin = val1; opacityMax = val2; zoomMin = 1  ; zoomMax = 2;
        }
        else if (zoom < 0) {
            opacityMin = val2; opacityMax = val1; zoomMin = 0.5; zoomMax = 1;
        }

        const zoomRange        = (zoomMax - zoomMin);
        const opacityRange     = (opacityMax - opacityMin);
        const zoomLevelPercent = (zoomLevel - zoomMin) / zoomRange;
        const opacityLevel     = (opacityRange * zoomLevelPercent) + opacityMin;
        return opacityLevel;

    }

}

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

// Fonctions

function init() {

    // Déterminer la résolution d'affichage

    stdCanvasW = document.body.clientWidth - 2 * (canvas.offsetLeft);
    stdCanvasH = stdCanvasW / 2;
    optCanvasW = stdCanvasW * window.devicePixelRatio;
    optCanvasH = stdCanvasH * window.devicePixelRatio;

    if (window.devicePixelRatio > 1) {

        canvas.width  = optCanvasW;
        canvas.height = optCanvasH;
        context.scale(window.devicePixelRatio, window.devicePixelRatio);

    }
    else {

        canvas.width  = stdCanvasW;
        canvas.height = stdCanvasH;

    }

    canvas.style.width  = stdCanvasW + "px";
    canvas.style.height = stdCanvasH + "px";

    lastAxisPosX = stdCanvasW / 2
    lastAxisPosY = stdCanvasH / 2

    // Créer et afficher la grille
    grid = new Grid();
    grid.draw();

}

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

// Démarrage de la webapp

init();

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

// Evenements

window.addEventListener("resize", init);

// Zoomer le canvas avec la roulette
canvas.addEventListener('wheel', function (e) {

    e.preventDefault();
    e.stopPropagation();

    zoom = e.wheelDelta / 120;
    grid.setZoom();

    // Get last (0;0) coordinates
    lastAxisPosX = grid.axisPosX;
    lastAxisPosY = grid.axisPosY;

})

// Pan canvas on drag 

canvas.addEventListener('mousedown', function (e) {

    e.preventDefault();
    e.stopPropagation();

    mouseStartX = parseInt(e.clientX) - canvas.offsetLeft;
    mouseStartY = parseInt(e.clientY) - canvas.offsetTop;

    canvas.onmousemove = function (e) {

        e.preventDefault();
        e.stopPropagation();

        grid.setPan();

    }

})

//  Récupérer les coordonnées de la souris en mouvement.
canvas.addEventListener('mousemove', function (e) {

    e.preventDefault();
    e.stopPropagation();

    mousePosX = parseInt(e.clientX) - canvas.offsetLeft;
    mousePosY = parseInt(e.clientY) - canvas.offsetTop;

})

canvas.addEventListener('mouseup', function (e) {

    e.preventDefault();
    e.stopPropagation();

    canvas.onmousemove  = null;
    canvas.onmousewheel = null;

    // Get last (0;0) coordinates
    lastAxisPosX = grid.axisPosX;
    lastAxisPosY = grid.axisPosY;

})

canvas.addEventListener('mouseout', function (e) {

    e.preventDefault();
    e.stopPropagation();

    canvas.onmousemove  = null;
    canvas.onmousewheel = null;

    // Get last (0;0) coordinates
    lastAxisPosX = grid.axisPosX;
    lastAxisPosY = grid.axisPosY;

})

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

#canvas{
    margin:20px;
    border: 1px solid white;
}
<canvas id="canvas"></canvas>

I think the grid will be more readable if you set the snippet screen to full size.

If I need to make some changes to make the whole thing more readable, just ask. But know that the part that is related to my problem is the first few lines of the draw() function within the Grid class (line 51 and 73).

Thanks


Solution

  • I fixed your code. Basically I had to ensure the shift calculation also accounts for ratio too and is shifted back after the zoom calculations.

    https://codesandbox.io/s/infinite-grid-2ocxcg?file=/src/index.js