0

I'm currently working on adding a panning system to the canvas in my Vue app. While I've successfully implemented the panning system, I'm facing challenges rendering an infinite grid of dots efficiently.

Here's a code of my component:

<template>
  <div>
    <div>
      <canvas
        @mousedown="baseEditorMouseDown"
        @mouseup="baseEditorMouseUp"
        @mousemove="baseEditorMouseMove"
        ref="baseEditor"
      ></canvas>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, type Ref, computed, onMounted } from "vue";

const baseEditor: Ref<HTMLCanvasElement | null> = ref(null);

const context: Ref<CanvasRenderingContext2D | null> = ref(null);
const gridOffset = ref(50);
const imagePosition: Ref<any> = ref({
  x: 0,
  y: 0,
});

const canvasBaseImage = ref(new Image());
const isMasking = ref(false);
const lastMaskPosition: Ref<any> = ref({
  x: 0,
  y: 0,
});

const isPanning = ref(false);
const lastClickPosition = ref({ x: 0, y: 0 });
const lastOffsetTemp = ref({ x: 0, y: 0 });
const lastOffset = ref({ x: 0, y: 0 });

const computedGridCircles = computed(() => {
  var coords = [];
  for (
    let x = -window.innerWidth + gridOffset.value / 2;
    x < window.innerWidth * 2;
    x += gridOffset.value
  ) {
    for (
      let y = -window.innerHeight + gridOffset.value / 2;
      y < window.innerHeight * 2;
      y += gridOffset.value
    ) {
      coords.push({
        x,
        y,
      });
    }
  }

  return coords;
});

function drawGridCircles() {
  if (context.value) {
    context.value.beginPath();

    for (let i = 0; i < computedGridCircles.value.length; i++) {
      const coord = computedGridCircles.value[i];
      context.value.rect(
        coord.x + lastOffsetTemp.value.x + lastOffset.value.x,
        coord.y + lastOffsetTemp.value.y + lastOffset.value.y,
        3,
        3
      );
    }
    context.value.fillStyle = "#333";
    context.value.fill();
  }
}

function baseEditorMouseMove(event: MouseEvent) {
  if (isPanning.value) {
    lastOffset.value.x = event.clientX - lastClickPosition.value.x;
    lastOffset.value.y = event.clientY - lastClickPosition.value.y;
    updatePanning();
  }
}

function baseEditorMouseDown(event: MouseEvent) {
  isPanning.value = true;
  lastClickPosition.value.x = event.clientX;
  lastClickPosition.value.y = event.clientY;
}

function baseEditorMouseUp(event: MouseEvent) {
  isMasking.value = false;
  isPanning.value = false;

  lastOffsetTemp.value.x += lastOffset.value.x;
  lastOffsetTemp.value.y += lastOffset.value.y;
}

function clearCanvas() {
  if (context.value) {
    context.value.clearRect(0, 0, window.innerWidth, window.innerHeight);
  }
}
function updatePanning() {
  clearCanvas();
  drawGridCircles();
}

onMounted(() => {
  if (baseEditor.value) {
    context.value = baseEditor.value.getContext("2d");

    baseEditor.value.width = window.innerWidth;
    baseEditor.value.height = window.innerHeight;
  }

  drawGridCircles();
});
</script>

In the computedGridCircles function, I calculate coordinates for grid dots based on the user's window size. However, this doesn't provide an infinite grid. I've referred to several resources, including:

but I'm missing something in the math calculations.

Here's a StackBlitz demo for better understanding. https://stackblitz.com/edit/vue3-vite-typescript-starter-21gys6?file=src%2FApp.vue

I appreciate any guidance on achieving an infinite grid with efficient rendering. Thank you for your help!

2 Answers 2

1

I believe I've identified the solution.

Firstly, a user cannot grab more than their screen resolution allows. Therefore, I reasoned that the differences between start and stop coordinates should exceed the screen resolution.

For instance, I have a 1920x1080 monitor, so the difference between start and stop X positions should be greater than 1920 to give the appearance of an infinite scroll. To ensure this, I also added five times the grid's size to the difference. The same principle applies to vertical positions.

I draw rectangles equivalent to the visible area to prevent lag or freezing issues after grabbing too much.

This is how I modified my drawGridCircles function:

function drawGridCircles() {
  if (context.value) {
    context.value.beginPath();

    const startX = Math.floor(-currentOffsetPositions.value.x / gridSize.value) * gridSize.value + gridSize.value / 2;
    const startY = Math.floor(-currentOffsetPositions.value.y / gridSize.value) * gridSize.value + gridSize.value / 2;

    const finishX = startX + window.innerWidth + gridSize.value * 5
    const finishY = startY + window.innerHeight + gridSize.value * 5

    for (let x = startX; x < finishX; x += gridSize.value) {
      for (let y = startY; y < finishY; y += gridSize.value) {
        context.value.rect(x + currentOffsetPositions.value.x, y + currentOffsetPositions.value.y, 3, 3)
      }
    }


    context.value.fillStyle = "#333";
    context.value.fill();
  }
}

The entire code can be found here: https://stackblitz.com/edit/vue3-vite-typescript-starter-qf1cey?file=src%2FApp.vue

Sign up to request clarification or add additional context in comments.

Comments

1

Infinite dots

The method below creates a infinite scroll dot field (Not really infinite as JavaScript uses doubles which has a limited range, size of scroll area is from Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER pixels in width and height)

The method creates a random set of dots with each dot having a x and y modulo value which is at least the size of the canvas.

Example getting the dot x position and test if visible

const mod = dot.mx; // modulo > canvas.width + DOT_SIZE
const x = ((dot.x - origin.x) % mod + mod) % mod - HALF_DOT_SIZE;
const visible = x >= -HALF_DOT_SIZE && x < canvas.width + HALF_DOT_SIZE;

Do this for both x, and y as in example snippet below in the function drawDots

The result is each dot is repeated randomly, in combination this creates an almost infinite non repeating pattern of dots.

This works very well for fixed size canvas, however it does not work at all if the canvas is changing size (such as zooming in and out);

Example

Some of the constants

  • DOT_PER_PIXEL is the density of dots

    NOTE that this value should be small as the canvas can contain millions of pixels. The value use in the example is 1 dot per 10,000 pixels. As the pattern of dots is random this is an approximation.

    The total number of dots needed to maintain that density as you pan is calculated for you;

  • DOT_SIZE Size of dot is needed to ensure dots do not pop in and out at the edges.

The example scrolls by moving the origin. The origin is moved in a direction and speed that changes over time.

requestAnimationFrame(mainLoop);
const ctx = canvas.getContext("2d");
var width = canvas.width;
var height = canvas.height;
const DOT_SIZE = 4, HALF_DOT_SIZE = DOT_SIZE * 0.5;
const rnd = (min, max) => (max - min) * Math.random() + min;
const lerp = (a, b, u) => (b - a) * u + a;
const DOTS_PER_PIXEL = 2 / 10000; // WARNING dots per pixel should be much smaller than 1
var dir = 0;                      // Origin movement dir and speed
var speed = 1;
const origin = {x: 0, y: 0};      // Any start pos will do
const dots = [];
const resized = (() => {
  var maxDistBetween;
  var minDistBetween;
  var dotsPerCanvas;
  var dotCount;
  function createDots(count) {
    const Dot = () => ({
      x: rnd(-HALF_DOT_SIZE, maxDistBetween),
      y: rnd(-HALF_DOT_SIZE, maxDistBetween),
      mx: rnd(minDistBetween, maxDistBetween),
      my: rnd(minDistBetween, maxDistBetween),
    });  
    while (count-- > 0) { dots.push(Dot()); }
  }
  return () => {
    width = innerWidth;
    height = innerHeight;
    canvas.width = width;
    canvas.height = height;
    ctx.strokeStyle = "#FFF";
    ctx.fillStyle = "#FFF";
    maxDistBetween = Math.max(width + DOT_SIZE, height + DOT_SIZE) * 2;
    minDistBetween = maxDistBetween * 0.6; // scale must be > 0.5
    dotsPerCanvas = width * height * DOTS_PER_PIXEL;
    dotCount = ((maxDistBetween * maxDistBetween) / ((width + DOT_SIZE) * (height + DOT_SIZE))) * dotsPerCanvas | 0;
    dots.length = 0;
    createDots(dotCount)
  }
})();

const updateOrigin = (() => {
    var turnFrom = 0;
    var turnTo = 0;
    var speedFrom = 0;
    var speedTo = 0;
    var timeFrom = 0;
    var timeTo = 0;
    return time => {
        if (timeTo < time) {
          timeFrom = time;
          timeTo = time + rnd(2000, 5000);
          turnFrom = turnTo;
          speedFrom = speedTo;
          turnTo += rnd(-1, 1);
          speedTo = rnd(5, 10);
       }
       const u = (time - timeFrom) / (timeTo - timeFrom);
       dir = lerp(turnFrom, turnTo, u);
       speed = lerp(speedFrom, speedTo, u);
       origin.x += Math.cos(dir) * speed;
       origin.y += Math.sin(dir) * speed;
    }
})();

function drawDots(origin, dots) {
  ctx.beginPath();
  for (const dot of dots) {
    const x = ((dot.x - origin.x) % dot.mx + dot.mx) % dot.mx - HALF_DOT_SIZE;
    if (x > -HALF_DOT_SIZE && x < width + HALF_DOT_SIZE) {
      const y = ((dot.y - origin.y) % dot.my + dot.my) % dot.my - HALF_DOT_SIZE;
      if (y > -HALF_DOT_SIZE && y < height + HALF_DOT_SIZE) {
        ctx.rect(x - HALF_DOT_SIZE, y - HALF_DOT_SIZE, DOT_SIZE, DOT_SIZE);    
      }
    }  
  }  
  ctx.fill();
}

function mainLoop(time) {
    if (width !== innerWidth || height !== innerHeight) { resized() }
    ctx.clearRect(0, 0, width, height);
    updateOrigin(time);
    drawDots(origin, dots);
    requestAnimationFrame(mainLoop);
}
body { background: #000; padding: 0px; margin: 0px; }
canvas {
  position: absolute;
  left: 0px;
  top: 0px;
  padding: 0px; 
  margin: 0px;
}
<canvas id="canvas" width="512" height="512"></canvas>

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.