subUser
subUser

Reputation: 107

How can I make infinite grid with canvas?

Nice example-grid can be in this site: https://playgameoflife.com, where you can click and move around this grid. So I would like to learn how to make such an endless grid that you can move on.

Snippet: https://jsfiddle.net/omar_red/wfsLuynd/1/

canvas = document.querySelector('.field');
ctx = canvas.getContext('2d');

canvas.width = window.screen.width;
canvas.height = window.screen.height;


for (let x = 0.5; x < canvas.width; x += 10) {
    ctx.moveTo(x, 0);
    ctx.lineTo(x, canvas.height);
}

for (let y = 0.5; y < canvas.height; y += 10) {
    ctx.moveTo(0, y);
    ctx.lineTo(canvas.width, y);
}

ctx.strokeStyle = "#888";
ctx.stroke();
body {
  margin: 0;
  padding: 0;
}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>Infinite Grid</title>
</head>
<body>
  <canvas class="field"></canvas>
</body>
</html>

Upvotes: 3

Views: 7634

Answers (2)

jadinerky
jadinerky

Reputation: 100

enter image description here

There are a lot of approaches to make the same effect but with some testing i found that the most flexible one is to manage the grid/drawing/anything else in a state, so the animation that will be generated when scrolling is depending on the values in the state (also what part of the drawing is getting rendered is depending on the state, since the state would tell us what part is visible, where does the drawing start, where does it stop , etc...).

NOTE: my testing was limited to drawing apps.

State properties

Property Type Description
height number the height of the cnavas element (user screen)
width number the width of the cnavas element (user screen)
_height number the real height of the drawing, so it can be bigger than height
_width number the real width of the drawing, so it can be bigger than width
top number Y position of the canvas box height within the drawing box _height
left number X position of the canvas box width within the drawing box _width
track.top number only used to track the Y scrolling
track.left number only used to track the X scrolling

Although it seams like we're over complicating a simple concept but now our app will benefit from a certain degree of awareness to the real-size of the canvas and the visible-size and will also use the top and left to keep track of where the visible-size is located inside the real-size

NOTE: _height and _width do not have a max value, so the user can keep scrolling forever and we keep adding the size he scrolled to one of them.

A working demo with wheel + dragging scroll + an example of zooming (you still need to make some changes to this one)

const state = {
    keys: [],
    mouse: {
        down: false,
        before: {
            x: 0,
            y: 0
        },
        now: {
            x: 0,
            y: 0
        }
    },
    size: {
        height: 0, // canvas Height
        width: 0, // Canvas Width
        _height: 0, // Real Height
        _width: 0, // Real Width
        top: 0,
        left: 0,
        track: { // track how much have we moved in any direction
            top: 0,
            left: 0
        }
    },
    tool: 'hand', // hand is the tool that can grab and move around the canvas unlike cursor
    ctx: null, // Used for the grid
    /*
    * used to draw selection, selection boxes, ghost elements, anything that needs a high refresh rate
    * so we can render it without re-rendering every other element
    * */
    prototyping: null
}








/*
* Init The State
* * * * * * * * * * */
const containerRect = document.querySelector('.container').getBoundingClientRect();
state.size.height = containerRect.height
state.size.width = containerRect.width

// this should be the real width and height, so replace it with your drawing real height and width
// or leave it as it is for new drawings
state.size._height = containerRect.height
state.size._width = containerRect.width








/*
* Draw Grid
* * * * * * * * * * */
const drawGrid = () => {
    const space = 150;
    // top is the position where the first line should be drawn (it's this part that gives the scrolling illusion)
    // left is the position where the first line should be drawn 
    const top = (- state.size.track.top % space);
    const left = (- state.size.track.left % space); // - space is used only to reverse the direction of the lines
    
    // clear the canvas
    state.ctx.clearRect(0, 0, state.size.width, state.size.height);
    
    // draw the 1px grid lines on the x axis
    for (let i = top; i < state.size.height; i += space) {
        state.ctx.beginPath();
        state.ctx.moveTo(0, i);
        state.ctx.lineTo(state.size.width, i);
        state.ctx.strokeStyle = '#ccc';
        state.ctx.stroke();
    }
    
    // draw the 1px grid lines on the y axis
    for (let i = left; i < state.size.width; i += space) {
        state.ctx.beginPath();
        state.ctx.moveTo(i, 0);
        state.ctx.lineTo(i, state.size.height);
        state.ctx.strokeStyle = '#ccc';
        state.ctx.stroke();
    }
    
    // draw Both X and Y Scrollbars to show the scrolling animation and percentage
    const yHeight = (state.size.height / state.size._height) * state.size.height
    const xWidth = (state.size.width / state.size._width) * state.size.width
    const yTop = (state.size.top / state.size._height) * state.size.height
    const xLeft = (state.size.left / state.size._width) * state.size.width
    const sSize = 10; // scrollbar size
    state.ctx.fillStyle = 'rgba(79,79,79,0.42)'
    state.ctx.beginPath();
    state.ctx.moveTo(state.size.width - sSize, yTop);
    state.ctx.arcTo(state.size.width, yTop, state.size.width, yTop + sSize, sSize / 2);
    state.ctx.arcTo(state.size.width, yTop + yHeight, state.size.width - sSize, yTop + yHeight, sSize / 2);
    state.ctx.arcTo(state.size.width - sSize, yTop + yHeight, state.size.width - sSize, yTop + yHeight - sSize, sSize / 2);
    state.ctx.arcTo(state.size.width - sSize, yTop, state.size.width, yTop, sSize / 2);
    state.ctx.fill();
    state.ctx.beginPath();
    state.ctx.moveTo(xLeft, state.size.height - sSize);
    state.ctx.arcTo(xLeft, state.size.height, xLeft + sSize, state.size.height, sSize / 2);
    state.ctx.arcTo(xLeft + xWidth, state.size.height, xLeft + xWidth, state.size.height - sSize, sSize / 2);
    state.ctx.arcTo(xLeft + xWidth, state.size.height - sSize, xLeft + xWidth - sSize, state.size.height - sSize, sSize / 2);
    state.ctx.arcTo(xLeft, state.size.height - sSize, xLeft, state.size.height, sSize / 2);
    state.ctx.fill();
    
}







/*
* Mouse Scrolling Handler
* * * * * * * * * * */
const handelMouseScroll = () => {
    // if the mouse is down and the tool is hand
    if (state.mouse.down && state.tool === 'hand') {
        const xDistance = state.mouse.now.x - state.mouse.before.x;
        const yDistance = state.mouse.now.y - state.mouse.before.y;
        
        // handel Y Scrolling
        if (yDistance >= 0) { // Up
            if (state.size.top === 0){ // When reaching top
                state.size._height += yDistance
            } else { // When There is space to scroll
                state.size.top = (state.size.top - yDistance) >= 0 ? (state.size.top - yDistance) : 0
            }
            state.size.track.top -= yDistance
        } else { // DOWN
            if (state.size.top + state.size.height === state.size._height){ // when reaching bottom
                state.size._height -= yDistance
                state.size.top -= yDistance
            } else { // When there is space to scroll
                state.size.top = (state.size.top - yDistance) <= state.size._height - state.size.height ? (state.size.top - yDistance) : state.size._height - state.size.height
            }
            state.size.track.top -= yDistance
        }
        
        
        // handel X Scrolling
        if (xDistance >= 0) { // Right
            if (state.size.left === 0){ // When reaching right
                state.size._width += xDistance
            } else { // When There is space to scroll
                state.size.left = (state.size.left - xDistance) >= 0 ? (state.size.left - xDistance) : 0
            }
            state.size.track.left -= xDistance
        } else { // Left
            if (state.size.left + state.size.width === state.size._width) { // when reaching left
                state.size._width -= xDistance
                state.size.left -= xDistance
            } else { // When there is space to scroll
                state.size.left = (state.size.left - xDistance) <= state.size._width - state.size.width ? (state.size.left - xDistance) : state.size._width - state.size.width
            }
            state.size.track.left -= xDistance
        }
        
        // update the mouse before position
        state.mouse.before.x = state.mouse.now.x
        state.mouse.before.y = state.mouse.now.y
        
        // draw the grid
        drawGrid()
    }
}







/*
* Cursor Selection... handler
* * * * * * * * * * */
const handelCursor = () => {
    // compare the mouse.before and mouse.now and draw the selection box
    if (state.mouse.down && state.tool === 'cursor') {
        const xDistance = state.mouse.now.x - state.mouse.before.x;
        const yDistance = state.mouse.now.y - state.mouse.before.y;
        state.prototyping.clearRect(0, 0, state.size.width, state.size.height);
        state.prototyping.fillStyle = 'rgba(0,123,255,0.25)'
        state.prototyping.fillRect(state.mouse.before.x, state.mouse.before.y, xDistance, yDistance);
        state.prototyping.strokeStyle = 'rgba(0,123,255,0.5)'
        state.prototyping.strokeRect(state.mouse.before.x, state.mouse.before.y, xDistance, yDistance);
        drawGrid()
    } else {
        // In case The Mouseup event fires we will call handelCursor but the mouse.down will be false so it will just clear the prototyping canvas
        // it's better if you create a loop to keep the prototyping in sync but in my simple case it's not needed
        state.prototyping.clearRect(0, 0, state.size.width, state.size.height);
    }
}







/*
* Wheel Scrolling Handler
* * * * * * * * * * */
const handelWheelScroll = (e) => {
    if (!state.keys.includes('shift')){
        
        // Y Scrolling
        if (e.deltaY > 0){
            state.size.track.top += 10
            if (state.size.top + state.size.height === state.size._height){
                state.size._height += 10
                state.size.top += 10
            } else {
                state.size.top = state.size.top + 10 <= state.size._height - state.size.height ? state.size.top + 10 : state.size._height - state.size.height
            }
        } else if (e.deltaY < 0) {
            state.size.track.top -= 10
            if (state.size.top > 0){
                state.size.top = state.size.top - 10 >= 0 ? state.size.top - 10 : 0
            } else {
                state.size._height += 10
            }
        }
        
    } else {
        
        // X Scrolling
        if (e.deltaY > 0){
            state.size.track.left += 10
            if (state.size.left + state.size.width === state.size._width){
                state.size._width += 10
                state.size.left += 10
            } else {
                state.size.left = state.size.left + 10 <= state.size._width - state.size.width ? state.size.left + 10 : state.size._width - state.size.width
            }
        } else if (e.deltaY < 0) {
            state.size.track.left -= 10
            if (state.size.left > 0){
                state.size.left = state.size.left - 10 >= 0 ? state.size.left - 10 : 0
            } else {
                state.size._width += 10
            }
        }
        
    }
    
    
    // draw the grid
    drawGrid();
}






/*
* Init The Canvas and Draw the first grid
* * * * * * * * * * */
const init = () => {
    const cvs = document.getElementById('canvas-space');
    cvs.setAttribute('width', state.size.width);
    cvs.setAttribute('height', state.size.height);
    state.ctx = cvs.getContext('2d');
    drawGrid();
    
    const prototyping = document.getElementById('prototyping-space');
    prototyping.setAttribute('width', state.size.width);
    prototyping.setAttribute('height', state.size.height);
    state.prototyping = prototyping.getContext('2d');
}







/*
* Init All Events
* * * * * * * * * * */
window.addEventListener('mousemove', (e) => {
    if (state.mouse.down && state.tool === 'hand') {
        state.mouse.now.x = e.clientX
        state.mouse.now.y = e.clientY
        
        handelMouseScroll()
    } else if (state.mouse.down && state.tool === 'cursor') {
        state.mouse.now.x = e.clientX
        state.mouse.now.y = e.clientY

        handelCursor()
    }
});

window.addEventListener('wheel', (e) => {
    handelWheelScroll(e)
})

window.addEventListener('mousedown', (e) => {
   state.mouse.down = true
    state.mouse.before.x = e.clientX
    state.mouse.before.y = e.clientY
    state.mouse.now.x = e.clientX
    state.mouse.now.y = e.clientY
});

window.addEventListener('mouseup', (e) => {
    state.mouse.down = false
    
    if (state.tool === 'cursor') {
        handelCursor()
    }
});

window.addEventListener('keydown', (e) => {
    if (!state.keys.includes(e.key.toLowerCase())) state.keys.push(e.key.toLowerCase())
});

window.addEventListener('keyup', (e) => {
    if (state.keys.includes(e.key.toLowerCase())) state.keys = state.keys.filter(k => k !== e.key.toLowerCase())
});

// Tools Changing Events
document.querySelectorAll('.tools [data-tool]').forEach(e => {
    e.addEventListener('click', (e) => {
        state.tool = e.target.getAttribute('data-tool');
        document.querySelector('.tools [data-tool].active').classList.remove('active')
        e.target.classList.add('active')
    })
});







/*
* Init The App And Enjoy (happy coding 😉)
* * * * * * * * * * * * */
init();
html, body {
    padding: 0;
    margin: 0;
    width: 100%;
    height: 100vh;
}
.container {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
}
.tools {
    position: absolute;
    bottom: 20px;
    border-radius: 8px;
    left: 50%;
    transform: translateX(-50%);
    box-shadow: 0 3px 12px 0 rgba(199, 199, 199, 0.61);
    display: flex;
    justify-content: space-between;
    padding: 5px;
    background: #ffffff;
}
.tools [data-tool]:nth-child(1) {
    margin-right: 5px;
}

.tools [data-tool] svg {
    pointer-events: none;
}

.tools [data-tool] {
    border-radius: 6px;
    height: 50px;
    width: 50px;
    display: flex;
    justify-content: center;
    align-items: center;
    cursor: pointer;
    transition: all ease-in-out .2s;
}
.tools [data-tool].active {
    background: #8b08f2;
}
.tools [data-tool].active svg {
    fill: #fff;
}


.container canvas {
    position: absolute;
    left: 0;
    top: 0;
}
<div class="container">
        <canvas id="canvas-space"></canvas>
        <canvas id="prototyping-space"></canvas>
        <div class="tools">
            <div data-tool="hand" class="active">
                <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" class="bi bi-hand-index" viewBox="0 0 16 16">
                    <path d="M6.75 1a.75.75 0 0 1 .75.75V8a.5.5 0 0 0 1 0V5.467l.086-.004c.317-.012.637-.008.816.027.134.027.294.096.448.182.077.042.15.147.15.314V8a.5.5 0 1 0 1 0V6.435a4.9 4.9 0 0 1 .106-.01c.316-.024.584-.01.708.04.118.046.3.207.486.43.081.096.15.19.2.259V8.5a.5.5 0 0 0 1 0v-1h.342a1 1 0 0 1 .995 1.1l-.271 2.715a2.5 2.5 0 0 1-.317.991l-1.395 2.442a.5.5 0 0 1-.434.252H6.035a.5.5 0 0 1-.416-.223l-1.433-2.15a1.5 1.5 0 0 1-.243-.666l-.345-3.105a.5.5 0 0 1 .399-.546L5 8.11V9a.5.5 0 0 0 1 0V1.75A.75.75 0 0 1 6.75 1zM8.5 4.466V1.75a1.75 1.75 0 1 0-3.5 0v5.34l-1.2.24a1.5 1.5 0 0 0-1.196 1.636l.345 3.106a2.5 2.5 0 0 0 .405 1.11l1.433 2.15A1.5 1.5 0 0 0 6.035 16h6.385a1.5 1.5 0 0 0 1.302-.756l1.395-2.441a3.5 3.5 0 0 0 .444-1.389l.271-2.715a2 2 0 0 0-1.99-2.199h-.581a5.114 5.114 0 0 0-.195-.248c-.191-.229-.51-.568-.88-.716-.364-.146-.846-.132-1.158-.108l-.132.012a1.26 1.26 0 0 0-.56-.642 2.632 2.632 0 0 0-.738-.288c-.31-.062-.739-.058-1.05-.046l-.048.002zm2.094 2.025z"/>
                </svg>
            </div>
            <div data-tool="cursor">
                <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" class="bi bi-cursor" viewBox="0 0 16 16">
                    <path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103zM2.25 8.184l3.897 1.67a.5.5 0 0 1 .262.263l1.67 3.897L12.743 3.52 2.25 8.184z"/>
                </svg>
            </div>
        </div>
    </div>

ANOTHER NOTE: you should consider turning the grid drawing function into a loop with requestAnimationFrame so that the event listener is faster, because how fast it executes is related to how fast it's callback is getting executed, and at the moment it's fast but not as fast as it can be.

Upvotes: 3

trincot
trincot

Reputation: 350079

You would have to handle the mouse events to know how much the cursor is moving with the left mouse button down.

The idea is then to move the coordinate system of the canvas by the same amounts as the cursor is moving. To avoid that blanks appear at the side we are moving away from, draw the grid 3 times as wide and high. That way you cannot get to the edge of the grid with one "drag" operation.

Then, when the button is released, restore the coordinate system back to its original state. So actually you undo the whole move. This is not apparent to the user, who gets the impression the grid just stops moving and snaps to a nice spot.

If you have real content in your "world" (like Conway's cells), then you will need to track how much your world coordinates have moved, and of course, those would not flip back to the original state. To fill in the cells in your grid, you would need to map world coordinates to grid coordinates. I have not covered this aspect here, as that would lead too far from your question.

So in the below implementation there is just the grid moving, but there is no notion of world coordinates or world content:

let canvas = document.querySelector('.field');
let ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

function draw() {
    let step = 10;
    let left = 0.5 - Math.ceil(canvas.width / step) * step;
    let top = 0.5 - Math.ceil(canvas.height / step) * step;
    let right = 2*canvas.width;
    let bottom = 2*canvas.height;
    ctx.clearRect(left, top, right - left, bottom - top);
    ctx.beginPath();
    for (let x = left; x < right; x += step) {
        ctx.moveTo(x, top);
        ctx.lineTo(x, bottom);
    }
    for (let y = top; y < bottom; y += step) {
        ctx.moveTo(left, y);
        ctx.lineTo(right, y);
    }
    ctx.strokeStyle = "#888";
    ctx.stroke();
}


// Mouse event handling:
let start;
const getPos = (e) => ({
    x: e.clientX - canvas.offsetLeft,
    y: e.clientY - canvas.offsetTop 
});

const reset = () => {
    start = null;
    ctx.setTransform(1, 0, 0, 1, 0, 0); // reset translation
    draw();
}

canvas.addEventListener("mousedown", e => {
    reset();
    start = getPos(e)
});

canvas.addEventListener("mouseup", reset);
canvas.addEventListener("mouseleave", reset);

canvas.addEventListener("mousemove", e => {
    // Only move the grid when we registered a mousedown event
    if (!start) return;
    let pos = getPos(e);
    // Move coordinate system in the same way as the cursor
    ctx.translate(pos.x - start.x, pos.y - start.y);
    draw();
    start = pos;
});

draw(); // on page load
body, html {
    width: 100%;
    height: 100%;
    margin: 0;
    padding:0;
    overflow:hidden;
}

canvas { background: silver; }
<canvas class="field"></canvas>

Upvotes: 8

Related Questions