Find an Empty Position in Group Node

Hi @walter

We’re working with Group Nodes and attempting to trigger a mouse enter event based on coordinates/position. Specifically, we’re trying to identify an empty position to explicitly trigger the mouse enter event on a particular group node while traversing through the nodes.

For example, if we have two group nodes, when focusing on the parent group node, we need to locate an empty slot and trigger a mouse enter event on that node. The same logic should apply to the child group node.

I tried the following code, but it does not function as expected for the inner group node.

We are looking for a generic code that helps to trigger mouse enter, considering all scaling, zoom-in & out.

function findEmptySpot(group) {
    let groupBounds = group.actualBounds;  // Get the bounding box of the group
    let diagram = group.diagram;

    // Iterate through positions inside the group bounds
    for (let x = groupBounds.x; x < groupBounds.right; x += 10) {  // Step size of 10 pixels
        for (let y = groupBounds.y; y < groupBounds.bottom; y += 10) {
            let point = new go.Point(x, y);

            // Find objects at this position
            let objectsAtPoint = diagram.findObjectsAt(point);

            // If only the group itself is found, this is an empty spot
            if (objectsAtPoint.count === 1 && objectsAtPoint.first() === group) {
                return point;  // Return this position as a safe spot
            }
        }
    }
    return null;  // No empty spot found
}

If you really want to simulate mouse/pointer/finger/stylus events, have you considered using the Robot extension?

Regarding your code, I would recommend calling Diagram.findPartsIn instead of Diagram.findObjectsIn, so that you won’t be bothered by overlapping GraphObjects within the same Node.

Yes, we are using the Robot extension to simulate mouse events.

This is our Group Node & we are looking to trigger mouse enter where no other GraphObjects are present on that specific Group Node & similar for Inner Group Nodes.

Tried a few things, but it is not working when we scale in/out & sometimes if the Group Node is not in the viewport completely in that scenario, it’s failing.

Any generic way we can achieve this?

Are your x,y coordinates that you pass in your calls to Robot methods all in document coordinates? Not in viewport/HTML coordinates?

One advantage of the Robot class, and of GoJS in general, is that there is very little that is in viewport coordinates, so the size and position of the viewport usually doesn’t matter.

We are using document coordinates only @walter

Here’s a complete stand-alone sample that shows nested Groups. Both Nodes and Groups have mouseEnter and mouseLeave event handlers that change the Shape.fill of one of their Shapes.

There is an unmodeled Part in the “Tool” Layer that acts as a visual indicator of where the virtual pointer is. The Robot instance is called to pretend to move the mouse along a path that goes downwards and leftwards, crossing over various Nodes and Groups. You can move that virtual pointer by clicking the only HTML button on the page.

I find that scrolling or zooming the diagram between button clicks does not break the behavior of the next call to move().

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <button id="myTestButton">Shift virtual pointer left & down</button>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/create-gojs-kit/dist/extensions/Robot.js"></script>
  <script id="code">
const myDiagram =
  new go.Diagram("myDiagramDiv");

myDiagram.nodeTemplate =
  new go.Node("Auto", {
      mouseEnter: (e, node) => node.findObject("SHP").fill = "yellow",
      mouseLeave: (e, node) => node.findObject("SHP").fill = "lightgreen",
    })
    .add(
      new go.Shape({ name: "SHP", fill: "lightgreen", stroke: "green" }),
      new go.TextBlock({ margin: 8 })
        .bind("text")
    );

myDiagram.groupTemplate =
  new go.Group("Vertical", {
      mouseEnter: (e, node) => node.findObject("SHP").fill = "cyan",
      mouseLeave: (e, node) => node.findObject("SHP").fill = "#CCCCCC08",
    })
    .add(
      new go.TextBlock({ font: "bold 12pt sans-serif" })
        .bind("text"),
      new go.Panel("Auto")
        .add(
          new go.Shape({ name: "SHP", fill: "#CCCCCC08", strokeWidth: 2 }),
          new go.Placeholder({ padding: 10 })
        )
    );

var myPointerX = 350
var myPointerY = -10;

const Pointer =
  new go.Part({
      layerName: "Tool",
      locationSpot: go.Spot.Center,
      location: new go.Point(myPointerX, myPointerY)
    })
    .add(
      new go.Shape("PlusLine", { width: 30, height: 30, stroke: "magenta" })
    );
myDiagram.add(Pointer);

const myRobot = new Robot(myDiagram);
var myRobotTime = 0;

function move() {
  myPointerX -= 10; myPointerY += 8;
  myRobot.mouseMove(myPointerX, myPointerY, myRobotTime);
  Pointer.location = new go.Point(myPointerX, myPointerY);
  myRobotTime += 100;
}

document.getElementById("myTestButton").addEventListener("click", e => move());

myDiagram.model = new go.GraphLinksModel([
  { key: 1, isGroup: true, text: "One" },
  { key: 2, isGroup: true, text: "Two" },
  { key: 11, isGroup: true, group: 1, text: "One one" },
  { key: 12, isGroup: true, group: 1, text: "One two" },
  { key: 13, group: 1, text: "One three" },
  { key: 121, group: 12, text: "One two one" },
  { key: 122, group: 12, text: "One two two" },
  { key: 123, group: 12, text: "One two three" },
  { key: 21, group: 2, text: "Two one" },
],
[
  { from: 1, to: 2 },
  { from: 11, to: 12 },
  { from: 12, to: 13 },
  { from: 121, to: 122 },
  { from: 122, to: 123 },
]);
  </script>
</body>
</html>

Currently, we are sending documentBounds to the Group Node.

const bounds = Group.getDocumentBounds();

robot.mouseMove(bounds.x, bounds.y, 0, {});

I just kept the console in our mouse trigger file & Robot file. The data is occurs as below:

For Loop(Outer one)

Group offset Rect {p: -2.1316282072803006e-14, w: -7.105427357601002e-15, j: 1097.6142374915394, q: 2357.2367960852894, h: false}

Robot.mouseMove 230.1928812542303 -894.1183980426447

For Loop1(Inner one)

Group offset Rect {p: 113.76142374915389, w: 254.81503703040397, j: 894.0913899932316, q: 1869.7894368682319, h: false}

Robot.mouseMove 343.9543050033842 -650.3947184341159

Why are we getting the Y value as -ve in mouseMove?

We expected that First Loop should highlight & then Loop1, but it’s not happening. Anything we are missing here?

This is the diagram:

For any GraphObject, GraphObject.getDocumentBounds will return its area in document coordinates. But its Rect.position, i.e. its x and y values are at the very top-left corner of the object, so it’s possible that trying to pick the object at its (x, y) point might miss the object altogether. Try offsetting the point so that you can be sure that the point is within the drawn surface of the object.

After all, when the user does a mouse down to select a Node, they are not going to try doing the mouse down exactly at the top-left bounds of the Node. They will click somewhere inside it.

On what basis can we offset Walter?

I tried giving some static +5, +10 it didn’t work. Tried with measuredBounds width/4, height/4 & that too doesn’t work.

That depends on the design of the node or link.

Judging from your screenshot, one of those offsets could have worked.

When you tried the code I just gave you, did the sample demonstrate the kind of behavior that you are looking for?

Yes Walter. The example you shared is similar which we are trying out.

Our findings here are:

  1. When the diagram is in viewport the logic which we wrote is working. But, when some part is hidden in this scenario its behaving oddly.

This is the code which we tried:

import go, { Link } from 'gojs';

/**
 * @param {go.GraphObject} item
 * @param {go.Diagram} diagram
 */
export default (item, diagram) => {
	// Get the bounds of the item and the scale
	const bounds = item.getDocumentBounds();
	const scale = diagram.scale;
	let docPoint = diagram.lastInput.documentPoint;  // Get mouse position in document coordinates
console.log('Group offset', `X: ${Math.round(docPoint.x)}, Y: ${Math.round(docPoint.y)}`);
	// Calculate the width and height of the item
	const width = item.measuredBounds.width * scale;
	const height = item.measuredBounds.height * scale;

	const offset = {
		x: width / 2,
		y: height / 2,
	};



	// @ts-ignore
	if(item.data.isGroup && item.isSubGraphExpanded) {	
	
	// @ts-ignore
		console.log('Group offset', bounds);
		

		return {
			middle: {
				// @ts-ignore
				x: bounds.x + width/4,
				// @ts-ignore
				y:   bounds.y + height/4,
			},
			topLeft: {
				x: bounds.x,
				y: bounds.y,
			},
		};

		
	}

	return {
		middle: {
			x: bounds.x + offset.x,
			y: bounds.y + offset.y,
		},
		topLeft: {
			x: bounds.x,
			y: bounds.y,
		},
	};
};


Its working when the diagram is in viewport. But, not when some part of the diagram is hidden.

What do you mean by “some part of the diagram is hidden”?

If we see the above image, we have an Outer Loop Node, which is not in the viewport. In this scenario, it’s not working.

Once we zoom out to fit the Group Node in the viewport it’s working as expected.

Or if you just scroll so that it’s visible?

Ah, I see the same issue in the sample I gave you. Due to virtualization, depending on fake mouse events actually working outside of the viewport is questionable anyway. And you wouldn’t expect it to work in a system where there are real PointerEvents simulating a mouse or finger or stylus. So maybe it’s for the best that you make sure you scroll the diagram so that what you want to work with is actually in the viewport.

Scrolling into the viewport may work for Other Nodes & Links. But, for a Group node that has few other Group Nodes that will occupy the whole screen/canvas, we don’t have an option to get those big Group Nodes into the viewport, as you see the same in the above image, Walter.

You just need to make sure that the point you want to click at or mouse over at is in the viewport. I don’t think you need to make sure the whole object is inside the viewport.

Here I have added a single statement to the move function in my sample:

function move() {
  myPointerX -= 10; myPointerY += 8;
  myDiagram.scrollToRect(new go.Rect(myPointerX-20, myPointerY-20, 40, 40));
  myRobot.mouseMove(myPointerX, myPointerY, myRobotTime);
  Pointer.location = new go.Point(myPointerX, myPointerY);
  myRobotTime += 100;
}

I believe that call to Diagram.scrollToRect is sufficient for your purposes.

Tried, as you mentioned Walter

diagram.scrollToRect(new go.Rect(GroupNode.getDocumentBounds().x-2, GroupNode.getDocumentBounds().y-2, 40, 40));

But it’s not working. The first ArrowDown triggers mouse enter on the inner Loop Node. Anything I am missing here?

I’m unable to discover a problem with the amended code that I gave you.

Can you modify my sample so that it has the problem that your app has?

I am not able to reproduce with the code that you gave Walter.

But, in our code, we depend upon GroupNode.getDocumentBounds(). I am unsure if it’s giving the expected X, Y coordinates. However, it’s working when the diagram is in the viewport.