Move nodes out of the way when group expands

I have a situation where users are going to be manually laying out nodes and I want them to, in general, stay in the place the user puts them. However, when a user expands a group, the expanded group will often overlap with other nodes. In this case, i’d like to have the group that expands ‘push’ other nodes out of the way (ie: when a group expands that would overlap nodes, those nodes should be moved down and to the right.)

What would be the best way to accomplish this?

I know a lot of the layouts will avoid overlaps, but they also will sort of destroy user positioning in the process which I dont want.

I think what you want to do is trickier than you might think.

Still, here’s some code to get you started:

  // before and after are Rects, copies of the part.actualBounds before expanding and after expanding
  function shiftNodes(part, before, after) {
    var dx = after.right - before.right;
    var dy = after.bottom - before.bottom;
    var diagram = part.diagram;
    if (diagram === null) return;
    var shiftsXY = new go.Set();
    var shiftsX = new go.Set();
    var shiftsY = new go.Set();
    diagram.nodes.each(function(n) {
      var b = n.actualBounds;
      if (b.left > before.right && b.top > before.bottom) shiftsXY.add(n);
      else if (b.left > before.right && b.bottom >= before.top && b.top < before.bottom) shiftsX.add(n);
      else if (b.right >= before.left && b.left < before.right && b.top > before.bottom) shiftsY.add(n);
    });
    diagram.moveParts(shiftsXY, new go.Point(dx, dy), false);
    diagram.moveParts(shiftsX, new go.Point(dx, 0), false);
    diagram.moveParts(shiftsY, new go.Point(0, dy), false);
  }

Thanks! But, how can we get “copies of the part.actualBounds before expanding and after expanding”?

Here’s a start. But it could be much smarter, which I’ll leave for you to implement.

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2021 by Northwoods Software Corporation. -->
  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
  function init() {
    var $ = go.GraphObject.make;

    myDiagram =
      $(go.Diagram, "myDiagramDiv",
        {
          "undoManager.isEnabled": true
        });

    myDiagram.nodeTemplate =
      $(go.Node, "Auto",
        $(go.Shape,
          { fill: "white" },
          new go.Binding("fill", "color")),
        $(go.TextBlock,
          { margin: 8 },
          new go.Binding("text", "key"))
      );
    
    myDiagram.groupTemplate =
      $(go.Group, "Auto",
        $(go.Shape, { fill: "transparent", strokeWidth: 3 },
          new go.Binding("stroke", "color"),
          new go.Binding("fill", "color", go.Brush.lighten)),
        $(go.Placeholder, { alignment: go.Spot.TopLeft, padding: 15 }),
        $("SubGraphExpanderButton", { alignment: go.Spot.TopLeft }),
        {
          minSize: new go.Size(50, 50),
          subGraphExpandedChanged: function(grp) {
            if (!grp.isSubGraphExpanded) return;
            shiftNodes(grp);
          },
          selectionChanged: function(grp) {
            grp.diagram.commit(function(diag) {
              var lay = grp.isSelected ? "Foreground" : "";
              grp.layerName = lay;
              grp.findSubGraphParts().each(function(x) { x.layerName = lay; });
            }, null);
          }
        }
      );

  function shiftNodes(part) {
    part.ensureBounds();
    var b = part.actualBounds;
    var diagram = part.diagram;
    if (diagram === null) return;
    var overlaps = diagram.findObjectsIn(b,
          function(x) { var p = x.part; return (p.isTopLevel && p instanceof go.Node) ? p : null; },
          function(node) { return node !== part && !node.isMemberOf(part); },
          true);
    var dx = 0;
    var dy = 0;
    var shiftsXY = new go.Set();
    var shiftsX = new go.Set();
    var shiftsY = new go.Set();
    overlaps.each(function(node) {
      var r = node.actualBounds;
      if (r.contains(b.right, b.bottom)) {
        dx = Math.max(dx, b.right - r.left);
        dy = Math.max(dy, b.bottom - r.top);
        shiftsXY.add(node);
      } else if (b.contains(r.left, r.bottom)) {
        dx = Math.max(dx, b.right - r.left);
        shiftsX.add(node);
      } else if (b.contains(r.right, r.top)) {
        dy = Math.max(dy, b.bottom - r.top);
        shiftsY.add(node);
      }
    });
    if (dx > 0) diagram.moveParts(shiftsX, new go.Point(dx+10, 0), false);
    if (dy > 0) diagram.moveParts(shiftsY, new go.Point(0, dy+10), false);
    if (dx > 0 && dy > 0) diagram.moveParts(shiftsXY, new go.Point(dx+10, dy+10), false);
  }

  myDiagram.model = new go.GraphLinksModel(
    [
      { key: 1, color: "lightblue", isGroup: true },
      { key: 2, color: "orange", isGroup: true },
      { key: 3, color: "lightgreen", isGroup: true },
      { key: 4, color: "pink", isGroup: true },
      { group: 1 },
      { group: 1 },
      { group: 1 },
      { group: 1 },
      { group: 2 },
      { group: 2 },
      { group: 2 },
      { group: 3 },
      { group: 3 },
      { group: 4 },
      { group: 4 },
    ],
    [
    ]);
  }
  </script>
</head>
<body onload="init()">
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>
</body>
</html>

thank you SO much. this is a huge help!

There were some bugs in my original code. I have edited the code above.

@walter thank you so much!

I see in your example you calculate the amount of overlap, which is really helpful. But, I’m still curious how to get the bounds of a group before and after expanding. Any suggestions?

It’s not needed – see the updated code.

@walter - We are trying the example code you supplied, but the bounds of the group being accessed in shiftNodes:

    var b = part.actualBounds;

That’s the bounds before the group is expanded. We really need the bound after it’s expanded for this to work. Any idea why we’re getting the bounds before?

Here’s our code:

Group template:

$(
    Group,
    "Auto",
    {
      mouseEnter: onMouseEnter,
      mouseLeave: onMouseLeave,
      subGraphExpandedChanged: function (grp: Group) {
        if (!grp.isSubGraphExpanded) return;
        shiftNodes(grp);
      },
    },
    { deletable: false },

    new Binding("isSubGraphExpanded", "expanded").makeTwoWay(),

    // This ensures collapsed groups are rendered in front of links
    // but expanded groups are rendered behind links
    new Binding("layerName", "isSubGraphExpanded", pickLayer).ofObject(),

    { layout: $(Layout), isSubGraphExpanded: false },
    $(
      Shape,
      "RoundedRectangle",
      new Binding("fill", "labelKey", (labelKey) => {
        const { backgroundColor } = labelConfigFromLabelKey(
          labelKey,
          labelData
        );
        return backgroundColor;
      }),
      {
        fill: White.L000,
        stroke: Black.L500,
        strokeWidth: 1,
      }
    ),
    $(
      Panel,
      "Table",
      { margin: 0.5, padding: 12 }, // avoid overlapping border with table contents
      $(RowColumnDefinition, { row: 0, background: null }), // header is white
      $("SubGraphExpanderButton", { row: 0, column: 0, margin: 3, padding: 3 }),
      $(
        TextBlock, // title is centered in header
        {
          row: 0,
          column: 1,
          font: "bold 14px Sans-Serif",
          stroke: Black.L500,
          textAlign: "center",
          stretch: GraphObject.Horizontal,
        },
        new Binding("text", "name"),
        new Binding("stroke", "labelKey", (labelKey) => {
          const { color } = labelConfigFromLabelKey(labelKey, labelData);
          return color;
        })
      ),
      $(
        Placeholder, // becomes zero-sized when Group.isSubGraphExpanded is false,
        { row: 1, columnSpan: 2, padding: 10, alignment: Spot.TopLeft,  },
        new Binding("padding", "isSubGraphExpanded", function (exp) {
          return exp ? 10 : 0;
        }).ofObject()
      )
    )
  );

And here is your shiftNode function, just changed to be Typescript:


function shiftNodes(group: Group) {
  console.log("shifting", group); 
  group.ensureBounds();
  const b = group.actualBounds;
  
  // !!! THESE ARE BOUNDS BEFORE EXPANDED
  console.log("b", b.x, b.y, b.width, b.height, b.top, b.bottom, b.left, b.right); 
  
  const diagram = group.diagram;
  if (diagram === null) return;
  const overlaps = diagram.findObjectsIn(b,
    function (x) { const p = x.part; return (p?.isTopLevel && p instanceof Node) ? p : null; },
    function (node) { return node !== group && !node.isMemberOf(group); },
    true);
  let dx = 0;
  let dy = 0;
  const shiftsXY = new Set<Part>();
  const shiftsX = new Set<Part>();
  const shiftsY = new Set<Part>();
  overlaps.each(function (node) {
    const r = node.actualBounds;
    if (r.contains(b.right, b.bottom)) {
      dx = Math.max(dx, b.right - r.left);
      dy = Math.max(dy, b.bottom - r.top);
      shiftsXY.add(node);
    } else if (b.contains(r.left, r.bottom)) {
      dx = Math.max(dx, b.right - r.left);
      shiftsX.add(node);
    } else if (b.contains(r.right, r.top)) {
      dy = Math.max(dy, b.bottom - r.top);
      shiftsY.add(node);
    }
  });
  if (dx > 0) diagram.moveParts(shiftsX, new Point(dx + 10, 0), false);
  if (dy > 0) diagram.moveParts(shiftsY, new Point(0, dy + 10), false);
  if (dx > 0 && dy > 0) diagram.moveParts(shiftsXY, new Point(dx + 10, dy + 10), false);
}

Ah, the first time we expand the group, the nodes haven’t been layed out yet. The second time we expand it, this does work.

Is there an onResize or similar callback we could hook into to also respond to the group size changing for this or any other reason?

The call to Part.ensureBounds makes sure it is measured and arranged properly, despite any possible changes, such as Group.isSubGraphExpanded having just changed value.

When I log the value of b.toString() after the call to ensureBounds, it’s definitely putting out the correct (new) bounds.

Here’s a video showing the bounds don’t include the full size of the layed out Placeholder on the first expand, only after we’ve expanded the group at least once: https://drive.google.com/file/d/1UQEbdWj2FvKFNQD1u3GH-qiSa7XYgR9c/view?usp=sharing

And here are the console logs, printed each time there’s a call to shiftNodes:

// Format is from console.log("b", b.width, b.height)
// First expand:
b 103.59061687161588 80.04569499661588

// Second expand:
b 190.79510499492383 189.8951049949238

Ah, you’re starting off with the group collapsed and the member nodes without real actualBoundss.

This is non-optimal, but instead of implementing a Group.subGraphExpandedChanged event handler, try:

      $(go.Diagram, . . .,
        {
          "LayoutCompleted": function(e) { e.diagram.findTopLevelGroups().each(shiftNodes); },

Thanks for all your help. We have managed to put together a solution that effectively moves nodes out of the way as groups (and nested groups) are expanded.

However, I’d still very much like to know if there’s any way to get the bounds of a group before and after expanding. Any way at all. I’d like to use it to determine a direction in which to push nodes, to make it feel a little more natural.

Well, you could save the previous bounds at the time of the expand/collapse:

          subGraphExpandedChanged: function(grp) {
            grp._before = grp.actualBounds.copy();
          },

Then in shiftNodes:

  function shiftNodes(part) {
    var diagram = part.diagram;
    if (diagram === null) return;
var after = part.actualBounds.copy();
if (part._before && !part._before.equals(after)) {
  console.log(part._before, after);
  part._before = undefined;
}
    . . .