Reputation: 107
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
Reputation: 100
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.
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
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