force-graph icon indicating copy to clipboard operation
force-graph copied to clipboard

Drag select (box)

Open Alexithemia opened this issue 3 years ago • 6 comments

Is your feature request related to a problem? Please describe. I see you have a getGraphBbox method, that will give you dimensions of a box containing all the nodes. I was hoping to get functionality to create a box and it would return all the nodes inside that box.

Describe the solution you'd like Add option to turn on drag(box) selection, shift+click and drag to create a box (just like you do on your desktop with applications) and it would return all nodes inside that box to a callback function.

Additional context I also use your 3D version and would be awesome to have it there too, but I know how much more difficult it would be.

Alexithemia avatar Oct 29 '20 18:10 Alexithemia

@Alexithemia I think you could build something like that on top of the force-graph component.

Most of the pieces should be in place:

  • You could render an additional div element on top of the existing canvas, to catch all the pointer drag interactions and potentially draw a visual selection box.
  • Once you have a bounding box, you can translate that to the graph coordinates domain using the screen2GraphCoords method. Then, you can easily find out which nodes are included in your box by simply running an intersection check for the nodes coordinates (x and y attributes).
  • From here on, the dragging functionality should be no different than the multiple node selection example.

I think this functionality is much too specific to support it inside the component, but having an example with this implementation could be useful to other developers.

As for 3D, the implementation would probably be quite similar, but step 2 is indeed more difficult because a screen box translates to not a plane polygon but a frustum, so the translation and intersection math is a bit more involved.

vasturiano avatar Oct 30 '20 04:10 vasturiano

I am not able to create an event listener for mousedown on the canvas or the container element of the graph, something seems to be blocking it. Do you know how to get that to go through? Or how would you get a div box to render? I want it to work when I shift+click+drag from anywhere (background or top of nodes).

It does work with 3D though.

I also need to be able to prevent dragging the graph or a node if shift is pressed during mousedown and mousedrag so I can create a box, but stopPropagation does not stop those from occurring on 3D currently.

Alexithemia avatar Nov 09 '20 19:11 Alexithemia

@Alexithemia the propagation of those pointer events is stopped by d3-zoom, as you can see from the documentation. d3-zoom is what handles the panning and zoom interactions on the graph.

However, you can still catch those events if you place a div element on top of the graph canvas (via absolute positioning). That's what I meant by point 1 previously.

You can also use this "event trapping" div to draw your drag box and potentially block the propagation of pointer drag events to the underlying graph canvas, when desired.

vasturiano avatar Nov 11 '20 07:11 vasturiano

I am probably missing something, but when I place a div over the graph it doesn't let any click events though it. There is a way to let the events through, by using pointerEvents: none in css. But then I can't use it to capture any events to make the box.

Alexithemia avatar Nov 21 '20 23:11 Alexithemia

Hi @Alexithemia. I was thinking that you actually wanted to handle all the pointer events in your overlay, and not let any through to the graph. You can instead just use the overlay for drawing (with pointer-events: none), and capture the pointer events for click drag etc in your graph DOM element.

As mentioned previously d3-zoom blocks the propagation of some of these events. However, there's a few ways around it:

  1. You can use pointer events instead of mouse events (used by d3-zoom) as the former have precedence over the latter. So addEventListener('pointerdown', ...) instead of addEventListener('mousedown', ...). Those should be triggered in your DOM element. You can even chose to propagate of cancel default conditionally to allow some events through to the graph.
  2. If you use the capture phase of the mousedown event you should be able to intercept it before it reaches the graph child element (and enters the bubble up phase). Again you can control whether to propagate them further or not. Something like: addEventListener('mousedown', myHandler, { useCapture: true }).

vasturiano avatar Nov 23 '20 03:11 vasturiano

Here is my code I used for 2D box select.

    // forceGraph element is the element provided to the Force Graph Library
    document.getElementById('forceGraph').addEventListener('pointerdown', (e) => {
      if (e.shiftKey) {
        e.preventDefault();
        boxSelect = document.createElement('div');
        boxSelect.id = 'boxSelect';
        boxSelect.style.left = e.offsetX.toString() + 'px';
        boxSelect.style.top = e.offsetY.toString() + 'px';
        boxSelectStart = {
          x: e.offsetX,
          y: e.offsetY
        };
        // app element is the element just above the forceGraph element.
        document.getElementById('app').appendChild(this.boxSelect);
      }
    });

    document.getElementById('forceGraph').addEventListener('pointermove', (e) => {
      if (e.shiftKey && boxSelect) {
        e.preventDefault();
        if (e.offsetX < boxSelectStart.x) {
          boxSelect.style.left = e.offsetX.toString() + 'px';
          boxSelect.style.width = (boxSelectStart.x - e.offsetX).toString() + 'px';
        } else {
          boxSelect.style.left = boxSelectStart.x.toString() + 'px';
          boxSelect.style.width = (e.offsetX - boxSelectStart.x).toString() + 'px';
        }
        if (e.offsetY < boxSelectStart.y) {
          boxSelect.style.top = e.offsetY.toString() + 'px';
          boxSelect.style.height = (boxSelectStart.y - e.offsetY).toString() + 'px';
        } else {
          boxSelect.style.top = boxSelectStart.y.toString() + 'px';
          boxSelect.style.height = (e.offsetY - boxSelectStart.y).toString() + 'px';
        }
      } else if (boxSelect) {
        boxSelect.remove();
      }
    });

    document.getElementById('forceGraph').addEventListener('pointerup', (e) => {
      if (e.shiftKey && boxSelect) {
        e.preventDefault();
        let left, bottom, top, right;
        if (e.offsetX < boxSelectStart.x) {
          left = e.offsetX;
          right = boxSelectStart.x;
        } else {
          left = boxSelectStart.x;
          right = e.offsetX;
        }
        if (e.offsetY < boxSelectStart.y) {
          top = e.offsetY;
          bottom = boxSelectStart.y;
        } else {
          top = boxSelectStart.y;
          bottom = e.offsetY;
        }
        runBoxSelect(left, bottom, top, right);
        boxSelect.remove();
      } else if (boxSelect) {
        boxSelect.remove();
      }
    });

    const runBoxSelect = (left, bottom, top, right) => {
      const tl = canvas.screen2GraphCoords(left, top);
      const br = canvas.screen2GraphCoords(right, bottom);
      const hitNodes = [];
      getNodesFromState(store).forEach(node => {
         if (tl.x < node.x && node.x < br.x && br.y > node.y && node.y > tl.y) {
              hitNodes.push(node);
         };
      });
      // run code to select your nodes here
      return selectGraphObjects(hitNodes);
    }

CSS for box to show up on top - 
#boxSelect {
    position: absolute;
    z-index: 300000;
    border-style: dotted;
    border-color: #3e74cc;
    background-color: rgba(255, 255, 255, 0.5);
    pointer-events: none;
}

Alexithemia avatar Nov 30 '20 15:11 Alexithemia