Search code examples
javascriptpanzoom

Calculating visible area in infinite webpage


I'm using @panzoom/panzoom package for creating infinite webpage where I have items placed on it. When user pans I need to lazyload items based on their x and y coordinates. To achieve lazyload of items I need to calculate visible area on the screen.

Working example: Codesandbox

const lazyload = e => {
    if (!panzoomInstance || !panzoomElem) return false;

    const scale = panzoomInstance.getScale();
    const pan = panzoomInstance.getPan();
    const { width, height } = document
      .getElementById("container")
      .getBoundingClientRect();

    const x1 = (0 - pan.x) / scale;
    const y1 = (0 - pan.y) / scale;
    const x2 = (width - pan.x) / scale;
    const y2 = (height - pan.y) / scale;

    const visibleArea = {
      x1: Math.floor(x1 * scale),
      y1: Math.floor(y1 * scale),
      x2: Math.floor(x2 * scale),
      y2: Math.floor(y2 * scale)
    };

    const itemsToLoad = items.filter(
      i =>
        i.x >= visibleArea.x1 &&
        i.x <= visibleArea.x2 &&
        i.y >= visibleArea.y1 &&
        i.y <= visibleArea.y2
    );

    console.log(`scale ${scale}`);
    console.log(`pan ${JSON.stringify(pan)}`);
    console.log("visibleArea", visibleArea);

    itemsToLoad.map(i => console.log(i.x, i.y));

    console.log(`found ${itemsToLoad.length} \n\n`);
};

Issue: Above calculation for getting visible area works fine when the scale is >= 1. Anything less than one will result in wrong calculation meaning the x and y coords I'm getting does not cover entire visible area.


Solution

  • First of all, two things you need to fix;

    1. The pan object — panzoomInstance.getPan() seems to return incorrect values somehow. Change that to document.getElementById("panzoom-container").getBoundingClientRect() like you did for the container.

    2. You are dividing xi and yi values by scale then multiplying with scale again, it won't change anything. Zooming out (means visible area increases) makes our scale factor smaller, so dividing the bounding area of our container by a smaller value makes the area bigger and for zooming in (means visible area decreases) makes our scale factor bigger, so dividing the bounding area of our container by a bigger value makes the area smaller. So, we need to remove the multiplying parts.

    Changes should look like this.

    const { top: panTop, left: panLeft } = document
      .getElementById("panzoom-container")
      .getBoundingClientRect();
    
    const x1 = (0 - panLeft) / scale;
    const y1 = (0 - panTop) / scale;
    const x2 = (width - panLeft) / scale;
    const y2 = (height - panTop) / scale;
    
    const visibleArea = {x1, y1, x2, y2};
    

    Secondly, you are only checking the top-left corner of each element. If the top-left corner is not visible, then the element itself is also not visible. This computation seems incorrect to me. Instead, using the intersection percent would be more accurate. However, if we want to check the intersection percent, we need to know the width and height properties of each element we have. If you're able to pass these properties along with the x and y properties that are already being passed, the code below should work perfectly. But if not, and you are okay with checking only the top-left corner, use the fix above.

    Codesandbox Demo

    const lazyload = e => {
      if (!panzoomInstance || !panzoomElem) return false;
    
      const {
        width: containerWidth,
        height: containerHeight
      } = document.getElementById("container").getBoundingClientRect();
    
      const { top: panTop, left: panLeft } = document
        .getElementById("panzoom-container")
        .getBoundingClientRect();
    
      const scale = panzoomInstance.getScale();
    
      const itemsToLoad = items.filter(i => {
        let bounding = {
          top: i.y * scale + panTop,
          left: i.x * scale + panLeft,
          right: (i.x + i.width) * scale + panLeft, // necessary for intersectionPercent
          bottom: (i.y + i.height) * scale + panTop // necessary for intersectionPercent
        };
    
        let intersection = intersectionPercent(
          { top: 0, left: 0, right: containerWidth, bottom: containerHeight },
          bounding
        );
    
        if (intersection > 0) { // use >= 0.5 for at least 50% intersection ratio
          console.log(i.id + " => " + intersection * 100 + "%");
          return true;
        }
    
        return false;
      });
      console.log(`found ${itemsToLoad.length} item(s) intersecting. \n`);
      console.log(itemsToLoad);
    };
    
    function intersectionPercent(a, b) {
      let i = {
        left: Math.max(a.left, b.left),
        right: Math.min(a.right, b.right),
        top: Math.max(a.top, b.top),
        bottom: Math.min(a.bottom, b.bottom)
      };
    
      if (i.left < i.right && i.top < i.bottom) {
        return (
          ((i.right - i.left) * (i.bottom - i.top)) /
          ((b.right - b.left) * (b.bottom - b.top))
        );
      }
      return 0;
    }
    

    I also added some CSS reset to the index.html file for annoying margin, padding, or box-sizing problems.