powerboy
powerboy

Reputation: 10961

Get DOM elements inside a rectangle area of a page

Given two points on a webpage and a set of DOM elements, how to find out the subset of those DOM elements that sit inside the rectangle area defined by the two points?

I am working on a web-based gallery, in which every photo is wrapped in a li tag. When a user drag out a rectangle area with mouse, all li elements inside the rectangle are marked as selected.

Prefer a jQuery solution for less wordy and an efficient way.

Upvotes: 19

Views: 9889

Answers (2)

chitzui
chitzui

Reputation: 4098

Hey @powerboy I had a similar use-case several years ago, let me list your options:

Using a library

This would be as easy as:

const ds = new DragSelect({
  selectables: document.querySelectorAll('.item')
});

For example:

const ds = new DragSelect({
  selectables: document.querySelectorAll('.item')
});
* { user-select: none; }

.item {
  width: 50px;
  height: 50px;
  position: absolute;
  color: white;
  border: 0;
  background: hotpink;
  top: 10%;
  left: 10%;
}

.ds-selected {
  outline: 3px solid black;
  outline-offset: 3px;
  color: black;
  font-weight: bold;
}

.item:nth-child(2),
.item:nth-child(4) { top: 50% }
.item:nth-child(3),
.item:nth-child(4) { left: 50% }
<script src="https://unpkg.com/dragselect@latest/dist/ds.min.js"></script>
<button type="button" class="item one">1</button>
<button type="button" class="item two">2</button>
<button type="button" class="item three">3</button>
<button type="button" class="item four">4</button>

The library comes with Drag-and-Drop included but if you just want the selection, without the drag and drop you can just turn it off setting draggability: false, (docs | example). In the docs you’ll also find a section on how to use it with your custom selection library.

i.e.:

const ds = new DragSelect({
  selectables: document.querySelectorAll('.item'),
  draggability: false
});

Context: as I was facing this challenge over a decade ago, I wrote this handy selection library. It is now very mature and will solve your use-case. I’d highly recommend using it rather than writing your own because it is a really really hard challenge where a lot can go wrong, still.

While writing DragSelect I tried various methods:

Elements under points

In theory we have document.elementFromPoint(x, y); (MDN) so we could do the following (simplified pseudocode):

const div = document.getElementById('myDiv');
const rect = div.getBoundingClientRect();
const elements = [];

for (let x = rect.left; x <= rect.right; x++) {
  for (let y = rect.top; y <= rect.bottom; y++) {
    const el = document.elementFromPoint(x, y);
    if (el && !elements.includes(el)) {
      elements.push(el);
    }
  }
}

This code loops through each point within the bounds of the myDiv element and uses document.elementFromPoint() to get the element at that point. It then adds the element to an array if it hasn't already been added. This will give you an array of all the elements within the bounds of the square div.

This seems the most straight forward method but it comes with downsides:

  • Only works for elements that are visible on the page.
  • Only takes the top-most element of that point, use elementsFromPoint to return all
  • Performance on a bigger square is pretty bad as the complexity is O(n^2) based on the size of the selection square. In my tests, this was the least performant method.

This however is awesome if you just need the element below a point.

Knowing this we could improve the algo a bit by checking if there is the ref rectangle from the middle point of the selectable element:

const boxes = document.querySelectorAll(".box");
const ref = document.querySelector("#ref");
const answer = document.querySelector("#answer");

document.addEventListener("mousemove", (e) => {
  ref.style.left = `${e.clientX}px`;
  ref.style.top = `${e.clientY}px`;
  
  let isColliding = false;
  boxes.forEach((box, index) => {
    const rect = box.getBoundingClientRect();
    const el = document.elementFromPoint((rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2);
    if (el === ref) {
      isColliding = true;
      box.style.background = "blue";
    } else {
      box.style.background = "grey";
    }
  });
  answer.innerText = isColliding ? "Collision with box" : "No collision";
});
.box {
  position: absolute;
  top: 20%;
  left: 20%;
  width: 15%;
  height: 15%;
  background: grey;
}
.box:nth-child(2),
.box:nth-child(4){ top: 60% }
.box:nth-child(3),
.box:nth-child(4){ left: 60% }

#ref {
  width: 15%;
  height: 15%;
  background: hotpink;
  position: fixed;
}
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div id="ref"></div>
<div id="answer"></div>

  • This now is of complexity O(n)
  • Which is nice and fun but way less flexible than using a collision algo:

Using a collision algo

Axis-aligned minimum bounding box

An enhancement of this one is what DragSelect is using under the hood. I explain it in details here, there is also an MDN article on 2D collision about it.

const boxes = document.querySelectorAll(".box");
const ref = document.querySelector("#ref");
const answer = document.querySelector("#answer");

document.addEventListener("mousemove", (e) => {
  ref.style.left = `${e.clientX}px`;
  ref.style.top = `${e.clientY}px`;
  const refRect = ref.getBoundingClientRect();

  let isColliding = false;
  boxes.forEach((box, index) => {
    const boxRect = box.getBoundingClientRect();
    if (AABBCollision(refRect, boxRect)) {
      isColliding = true;
      box.style.background = "blue";
    } else {
      box.style.background = "grey";
    }
  });
  answer.innerText = isColliding ? "Collision with box" : "No collision";
});

const AABBCollision = (a, b) => {
  if (
    a.left < b.right && // 1.
    a.right > b.left && // 2.
    a.top < b.bottom && // 3.
    a.bottom > b.top // 4.
  ) {
    return true;
  } else {
    return false;
  }
};
.box {
  position: absolute;
  top: 20%;
  left: 20%;
  width: 15%;
  height: 15%;
  background: grey;
}

.box:nth-child(2),
.box:nth-child(4) {
  top: 60%
}

.box:nth-child(3),
.box:nth-child(4) {
  left: 60%
}

#ref {
  width: 15%;
  height: 15%;
  background: hotpink;
  position: fixed;
}
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div id="ref"></div>
<div id="answer"></div>

  • For simplicity one square just follows the mouse, when there is a colision, the box will change from grey to blue and vice versa.

Let me explain:

Axis-Aligned Bounding Box Collision Detection. Imagine following Example:

        b01
     a01[1]a02
        b02      b11
              a11[2]a12
                 b12

to check if those two boxes collide we do this AABB calculation:

  1. a01 < a12 (left border pos box1 smaller than right border pos box2)
  2. a02 > a11 (right border pos box1 larger than left border pos box2)
  3. b01 < b12 (top border pos box1 smaller than bottom border pos box2)
  4. b02 > b11 (bottom border pos box1 larger than top border pos box2)

As you’ll have to do this check with every potential element you can collide with, this has (given you only have 1 reference element) theoretically a O(n) complexity which is great because and scales linearly with the amount of inputs.

This has proven effective in DragSelect for up to 30.000 elements, then it starts getting slow because getting bounding box is expensive in javascript and I am looking for a better algo as we speak luckily for 99.99% of use-cases 30k elements is enough :)

But will keep you updated with future findings (i.e. other algos or the broad and narrow phase might be interesting)

Hope I could help, cheers!

Upvotes: 1

ArtBIT
ArtBIT

Reputation: 3989

Try something like this:

// x1, y1 would be mouse coordinates onmousedown
// x2, y2 would be mouse coordinates onmouseup
// all coordinates are considered relative to the document
function rectangleSelect(selector, x1, y1, x2, y2) {
    var elements = [];
    jQuery(selector).each(function() {
        var $this = jQuery(this);
        var offset = $this.offset();
        var x = offset.left;
        var y = offset.top;
        var w = $this.width();
        var h = $this.height();

        if (x >= x1 
            && y >= y1 
            && x + w <= x2 
            && y + h <= y2) {
            // this element fits inside the selection rectangle
            elements.push($this.get(0));
        }
    });
    return elements;
}

// Simple test
// Mark all li elements red if they are children of ul#list
// and if they fall inside the rectangle with coordinates: 
// x1=0, y1=0, x2=200, y2=200
var elements = rectangleSelect("ul#list li", 0, 0, 200, 200);
var itm = elements.length;
while(itm--) {
    elements[itm].style.color = 'red';
    console.log(elements[itm]);
}

For a vanilla JS solution, check out this pen: https://codepen.io/ArtBIT/pen/KOdvjM

Upvotes: 14

Related Questions