Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Creating a draggable and scaleable grid in HTML5

Unlike the other HTML5 how to create a grid questions, I'm wondering how to make one draggable and scalable.

Drawing the grid is pretty simple:

var c = document.getElementById('canvas');
var ctx = c.getContext('2d');
var width = window.innerWidth;
var height = window.innerHeight;

c.width = width;
c.height = height;

drawGrid(width, height, 40);

function drawGrid(gridWidth, gridHeight, boxSize) {
  ctx.clearRect(0, 0, c.width, c.height);
  ctx.beginPath();
  for (var i = 0; i <= gridWidth; i += boxSize) {
    ctx.moveTo(i, 0);
    ctx.lineTo(i, gridHeight);
  }
  for (var i = 0; i <= gridHeight; i += boxSize) {
    ctx.moveTo(0, i);
    ctx.lineTo(gridWidth, i);
  }
  ctx.strokeStyle = "rgba( 210, 210, 210, 1 )";
  ctx.stroke();
}
html,
body {
  overflow: hidden;
}

#canvas {
  position: absolute;
  top: 0;
  left: 0;
}
<canvas id="canvas"></canvas>

Now to make it draggable, there are many ways of doing it, but I focused on creating the illusion of an infinite moving grid similar to this:

Example Image (Sorry, don't have enough credits yet)

As the graph moves toward the right, the lines that are hidden from the canvas size are moved back to the beginning, and vice versa. I'm not too sure how to go about moving the grid with the mouse, as well as the scaling. The lines tend to blur when scaled unlike SVGs. What's the quickest way of creating a grid to move around infinitely and scale?

EDIT: I took a similar approach to move the grid around using an image pattern to fill the screen.

var c = document.getElementById("canvas"),
  ctx = c.getContext("2d");
var width = window.innerWidth;
var height = window.innerHeight;
var itemIsSelected = false;
var clicked = function(e) {
  var x = e.pageX;
  var y = e.pageY;
}

draw(width, height);
draggable('#app');

function draw(width, height) {
  c.width = width;
  c.height = height;
  generateBackground();
}

function draggable(item) {
  var isMouseDown = false;
  document.onmousedown = function(e) {
    e.preventDefault();
    clicked.x = e.pageX;
    clicked.y = e.pageY;
    $(item).css('cursor', 'all-scroll');
    isMouseDown = true;
  };
  document.onmouseup = function(e) {
    e.preventDefault();
    isMouseDown = false;
    $(item).css('cursor', 'default');
  };
  document.onmousemove = function(e) {
    e.preventDefault();
    if (isMouseDown == true) {
      var mouseX = e.pageX;
      var mouseY = e.pageY;
      generateBackground(mouseX, mouseY, clicked.x, clicked.y);
    }
  };
}

function generateBackground(x, y, initX, initY) {
  distanceX = x - initX;
  distanceY = y - initY;
  ctx.clearRect(0, 0, c.width, c.height);
  var bgImage = document.getElementById("bg")
  var pattern = ctx.createPattern(bgImage, "repeat");
  ctx.rect(0, 0, width, height);
  ctx.fillStyle = pattern;
  ctx.fill();
  ctx.translate(Math.sqrt(distanceX), Math.sqrt(distanceY));
}
html,
body {
  overflow: hidden;
}

#canvas {
  top: 0;
  left: 0;
  position: absolute;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<canvas id="canvas"></canvas>
<img src="https://i.imgur.com/2MupHjw.png" id="bg" hidden>

This method does not allow me to scroll left or up. It also speeds up the dragging after a distance and does not deal with negative motion well.

like image 544
Tom Jaquo Avatar asked Nov 06 '25 04:11

Tom Jaquo


1 Answers

Pan and Zoom

I dont have much tine so the code will have to do most of the explaining.

The example below pans and zooms using the mouse and mouse wheel.

The object panZoom holds the information regarding the zoom (scale) and pan position (x, y)

To pan just use the change in mouse position to change the panZoom x, y position. You dont need to scale the mouse movements as they are not effected by the zoom.

The zoom is via a function panZoom.scaleAt(x,y,scale) where x, y is the mouse position and scaleBy is the amount to scale the scale by. Eg panZoom.scaleAt(100,100, 2) will zoom in 2 times at position 100,100 and panZoom.scaleAt(100,100, 1/2) will zoom out 2 times at the same position. See update function for more details.

To draw in the panZoom coordinate system you need to call the function panZoom.apply which sets the context transform to match panZoom's settings. The function drawGrid is an example. It draws a grid to fit the current pan and zoom.

Note that to restore the normal screen coordinates just call ctx.setTransform(1,0,0,1,0,0) which you will need to do if you want to clear the canvas.

Snippet update 2021

I have added

  • arguments for drawGrid(gridScreenSize, adaptive)

    • gridScreenSize size of grid in screen pixels (adaptive mode). In world pixels (static mode)

    • adaptive if true grid scale adapts to world scale. false grid size is fixed. When false the constant gridLimit sets the max number of lines to render

  • scaleRate constant that controls the rate of scaling. See comments for more.

  • display text to show current scale.

  • checkbox to switch between static and adaptive grid size.

const ctx = canvas.getContext("2d");
requestAnimationFrame(update);
const mouse  = {x : 0, y : 0, button : false, wheel : 0, lastX : 0, lastY : 0, drag : false};
const gridLimit = 64;  // max grid lines for static grid
const gridSize = 128;  // grid size in screen pixels for adaptive and world pixels for static
const scaleRate = 1.02; // Closer to 1 slower rate of change
                        // Less than 1 inverts scaling change and same rule
                        // Closer to 1 slower rate of change
const topLeft = {x: 0, y: 0};  // holds top left of canvas in world coords.


function mouseEvents(e){
    const bounds = canvas.getBoundingClientRect();
    mouse.x = e.pageX - bounds.left - scrollX;
    mouse.y = e.pageY - bounds.top - scrollY;
    mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
    if(e.type === "wheel"){
        mouse.wheel += -e.deltaY;
        e.preventDefault();
    }
}
["mousedown", "mouseup", "mousemove"].forEach(name => document.addEventListener(name,mouseEvents));
document.addEventListener("wheel",mouseEvents, {passive: false});

const panZoom = {
    x : 0,
    y : 0,
    scale : 1,
    apply() { ctx.setTransform(this.scale, 0, 0, this.scale, this.x, this.y) },
    scaleAt(x, y, sc) {  // x & y are screen coords, not world
        this.scale *= sc;
        this.x = x - (x - this.x) * sc;
        this.y = y - (y - this.y) * sc;
    },
    toWorld(x, y, point = {}) {   // converts from screen coords to world coords
        const inv = 1 / this.scale;
        point.x = (x - this.x) * inv;
        point.y = (y - this.y) * inv;
        return point;
    },
}
function drawGrid(gridScreenSize = 128, adaptive = true){
    var scale, gridScale, size, x, y, limitedGrid = false;
    if (adaptive) {
        scale = 1 / panZoom.scale;
        gridScale = 2 ** (Math.log2(gridScreenSize * scale) | 0);
        size = Math.max(w, h) * scale + gridScale * 2;
        x = ((-panZoom.x * scale - gridScale) / gridScale | 0) * gridScale;
        y = ((-panZoom.y * scale - gridScale) / gridScale | 0) * gridScale;
    } else {
        gridScale = gridScreenSize;
        size = Math.max(w, h) / panZoom.scale + gridScale * 2;
        panZoom.toWorld(0,0, topLeft);
        x = Math.floor(topLeft.x / gridScale) * gridScale;
        y = Math.floor(topLeft.y / gridScale) * gridScale;
        if (size / gridScale > gridLimit) {
            size = gridScale * gridLimit;
            limitedGrid = true;
        }            
    } 
    panZoom.apply();
    ctx.lineWidth = 1;
    ctx.strokeStyle = "#000";
    ctx.beginPath();
    for (i = 0; i < size; i += gridScale) {
        ctx.moveTo(x + i, y);
        ctx.lineTo(x + i, y + size);
        ctx.moveTo(x, y + i);
        ctx.lineTo(x + size, y + i);
    }
    ctx.setTransform(1, 0, 0, 1, 0, 0); // reset the transform so the lineWidth is 1
    ctx.stroke();
    
    info.textContent = "Scale: 1px = " + (1/panZoom.scale).toFixed(4) + " world px ";
    limitedGrid && (info.textContent += " Static grid limit " + (gridLimit * gridLimit) + " cells");    
}   
function drawPoint(x, y) {
    const worldCoord = panZoom.toWorld(x, y);
    panZoom.apply();
    ctx.lineWidth = 1;
    ctx.strokeStyle = "red";
    ctx.beginPath();   
    ctx.moveTo(worldCoord.x - 10, worldCoord.y);
    ctx.lineTo(worldCoord.x + 10, worldCoord.y);
    ctx.moveTo(worldCoord.x, worldCoord.y - 10);
    ctx.lineTo(worldCoord.x, worldCoord.y + 10); 
    ctx.setTransform(1, 0, 0, 1, 0, 0); //reset the transform so the lineWidth is 1
    ctx.stroke();  
}

var w = canvas.width;
var h = canvas.height;
function update(){
    ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
    ctx.globalAlpha = 1;           // reset alpha
    if (w !== innerWidth || h !== innerHeight) {
        w = canvas.width = innerWidth;
        h = canvas.height = innerHeight;
    } else {
        ctx.clearRect(0, 0, w, h);
    }
    if (mouse.wheel !== 0) {
        let scale = 1;
        scale = mouse.wheel < 0 ? 1 / scaleRate : scaleRate;
        mouse.wheel *= 0.8;
        if(Math.abs(mouse.wheel) < 1){
            mouse.wheel = 0;
        }
        panZoom.scaleAt(mouse.x, mouse.y, scale); //scale is the change in scale
    }
    if (mouse.button) {
       if (!mouse.drag) {
          mouse.lastX = mouse.x;
          mouse.lastY = mouse.y;
          mouse.drag = true;
       } else {
          panZoom.x += mouse.x - mouse.lastX;
          panZoom.y += mouse.y - mouse.lastY;
          mouse.lastX = mouse.x;
          mouse.lastY = mouse.y;
       }
    } else if (mouse.drag) {
        mouse.drag = false;
    }
    drawGrid(gridSize, adaptiveGridCb.checked);
    drawPoint(mouse.x, mouse.y);
    requestAnimationFrame(update);
}
canvas { position : absolute; top : 0px; left : 0px; }
div { 
  position : absolute; 
  top : 5px; 
  left : 5px; 
  font-family: arial;
  font-size: 16px;
  background: #FFFD;
}
<canvas id="canvas"></canvas>
<div>
<label for="adaptiveGridCb">Adaptive grid</label>
<input id="adaptiveGridCb" type="checkbox" checked/>
<span id="info"></span>
</div>
like image 102
Blindman67 Avatar answered Nov 07 '25 17:11

Blindman67