Issue with nodes not remaining centered within groups

Hi we’re having a bit of issue finding the route cause of this issue where our links seemingly go off center but if you look closely it seems to be that node ports don’t align from inner groups. This has only started happening as we upgraded to 3.0.4


Are you aware of any changes that would cause this? I have gone through the changelog a few times and nothing really indicated this type of issue.

Group layout code:

 layout: $(go.TreeLayout, {
          angle: 90,
          alignment: go.TreeAlignment.CenterSubtrees,
          layerStyle: go.TreeLayerStyle.Siblings,
          layerSpacing: LayoutConstants.standardLayerSpacing + 10,
          nodeSpacing: LayoutConstants.flowSwitchHorizontalSpacing,
          isRealtime: false,
          setsChildPortSpot: false,
          setsPortSpot: false
        })

this is what it looks like in 2.3

I just diff’ed the sources between the 2.3.17 and the 3.0.4 versions of TreeLayout, and all of the differences were due to replacing the old enumvalues with the new TypeScript enum values or to some internal name changes. I didn’t see any potential functionality differences.

We’ll look into this by trying to come up with an example.

[EDIT] I wonder how your situation is different from the ParallelLayout sample: Parallel Layout | GoJS

Here’s another example:

<!DOCTYPE html>
<html>

<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->

  <script src="go.js"></script>
  <script src="../site/extensions/ParallelLayout.js"></script>
  <script id="code">
    function init() {
      var $ = go.GraphObject.make;

      myDiagram =
        new go.Diagram("myDiagramDiv",
          {
            layout:
              $(go.TreeLayout,
                {
                  angle: 90,
                  layerSpacing: 30,
                  alignment: go.TreeLayout.AlignmentCenterSubtrees,
                  arrangement: go.TreeLayout.ArrangementHorizontal
                })
          });

      myDiagram.nodeTemplate =
        $(go.Node, "Auto",
          { width: 70, height: 35 },
          $(go.Shape,
            { fill: "white" }),
          $(go.TextBlock,
            new go.Binding("text", "key", function (k) { return "Block " + k; }))
        );

      myDiagram.nodeTemplateMap.add("Split",
        $(go.Node,
          { locationSpot: go.Spot.Center },
          $(go.Shape, "Circle", { width: 5, height: 5, fill: "white" })
        ));

      myDiagram.nodeTemplateMap.add("Merge",
        $(go.Node,
          { locationSpot: go.Spot.Center },
          $(go.Shape, "Circle", { width: 5, height: 5, fill: "white" })
        ));

      myDiagram.groupTemplate =
        $(go.Group,
          {
            layout:
              $(ParallelLayout,
                {
                  angle: 90,
                  layerSpacing: 30,
                  alignment: go.TreeLayout.AlignmentCenterSubtrees,
                })
          },
          $(go.Placeholder)
        );

      myDiagram.linkTemplate =
        $(go.Link,
          { routing: go.Link.Orthogonal, corner: 10 },
          $(go.Shape)
        );

      myDiagram.model = $(go.GraphLinksModel,
        {
          archetypeNodeData: {},  // automatically create simple nodes just by reference in link data
          linkDataArray:
            [
              { from: "S0", to: "S1" },
              { from: "S1", to: 1 },
              { from: "S1", to: 5 },
              { from: 1, to: "M1" },
              { from: 5, to: "M1" },
              { from: "S0", to: 3 },
              { from: 3, to: 6 },
              { from: "S0", to: 4 },
              { from: "M1", to: "M0" },
              { from: 6, to: "M0" },
              { from: 4, to: "M0" },
              { from: "M0", to: 2 },
              { from: 2, to: "S2" },
              { from: "S2", to: 7 },
              { from: 7, to: "M2" },
              { from: "S2", to: 8 },
              { from: 8, to: 9 },
              { from: 9, to: 10 },
              { from: 10, to: "M2" },
              { from: "S2", to: 11 },
              { from: 11, to: "S3" },
              { from: "S3", to: 12 },
              { from: 12, to: 13 },
              { from: 13, to: "M3" },
              { from: "S3", to: 14 },
              { from: 14, to: "M3" },
              { from: "M3", to: "M2" }
            ]
        });

      // organize into nested groups, before the initial layout happens
      myDiagram.findTreeRoots().each(walkGroups);
    }

    function isSplit(n) {
      return n instanceof go.Node && typeof n.data.key === "string" && n.data.key[0] === "S";
    }

    function isMerge(n) {
      return n instanceof go.Node && typeof n.data.key === "string" && n.data.key[0] === "M";
    }

    function walkGroups(node, gdata) {
      if (node instanceof go.Group) return;
      if (!node.isTopLevel) return;
      var model = node.diagram.model;
      if (isSplit(node)) {
        var grpdata = { isGroup: true };
        if (gdata) model.setGroupKeyForNodeData(grpdata, model.getKeyForNodeData(gdata));
        model.addNodeData(grpdata);
        model.setGroupKeyForNodeData(node.data, model.getKeyForNodeData(grpdata));
        model.setCategoryForNodeData(node.data, "Split");
        gdata = grpdata;
      } else {
        if (gdata) model.setGroupKeyForNodeData(node.data, model.getKeyForNodeData(gdata));
        if (isMerge(node)) model.setCategoryForNodeData(node.data, "Merge");
      }
      var cdata = isMerge(node) ? model.findNodeDataForKey(model.getGroupKeyForNodeData(gdata)) : gdata;
      node.findNodesOutOf().each(function (t) {
        walkGroups(t, cdata);
      });
    }
  </script>
</head>

<body onload="init()">
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:800px"></div>
</body>

</html>

Here’s another one, this time using LayeredDigraphLayout instead of TreeLayout:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="myDiagramDiv" style="border: solid 1px black; width: 100%; height: 700px"></div>
    <div>
        <button id="add">Add Box</button>
        <button onclick="load(0)">Reload 0</button>
        <button onclick="load(1)">Reload 1</button>
    </div>
    <script src="go.js"></script>
    <script>

// Route all orthogonal links to turn at the same relative x point.
// This only works for direction: 0.
class ParallelLDLayout extends go.LayeredDigraphLayout {
  constructor() {
    super();
    this.alignOption = go.LayeredDigraphLayout.AlignAll;
    this.layeringOption = go.LayeredDigraphLayout.LayerLongestPathSource;
    this.layerSpacing = 50;
    this.linkSpacing = 20;
  }

  commitLinks() {
    super.commitLinks();
    this.network.vertexes.each(v => {
      if (v.node /*&& v.node.category === "group-end"*/) {
        // assume this.direction === 0
        let x = v.node.port.getDocumentBounds().x - 30;
        v.sourceEdges.each(e => {
          const link = e.link;
          if (link && link.isOrthogonal) {
            const num = link.pointsCount;
            if (num >= 6) {
              const pts = link.points.copy();
              const p3 = pts.elt(num-3);
              pts.setElt(3, new go.Point(x, p3.y));
              const p2 = pts.elt(num-4);
              pts.setElt(2, new go.Point(x, p2.y));
              link.points = pts;
            }
          }
        });
      }
    });
  }
}


function init() {
    const $ = go.GraphObject.make;

    myDiagram = new go.Diagram("myDiagramDiv",
      {
        layout: new ParallelLDLayout()
      });

    myDiagram.nodeTemplateMap.add("global", $(go.Node, "Spot",
      $(go.Shape, "Circle", {
        fill: "transparent",
        stroke: '#01778e',
        width: 50,
        height: 50,
        portId: "",
        fromLinkable: true,
        toLinkable: true,
        cursor: "pointer",
        }),
      $(go.TextBlock,
        new go.Binding("text", "key"),
        {
            verticalAlignment: go.Spot.Center,
            textAlign: "center",
            stroke: '#1d2024',
        }),
      $(go.Shape, "Rectangle", {
            fill: null,
            stroke: null,
            desiredSize: new go.Size(0, 0),
            alignment: new go.Spot(0, 0.5, 0, 0),
            portId: "Left",
        }),
      $(go.Shape, "Rectangle", {
            fill: null,
            stroke: null,
            desiredSize: new go.Size(0, 0),
            alignment: new go.Spot(1, 0.5, 0, 0),
            portId: "Right",
        }),
    ));

    myDiagram.nodeTemplate = $(go.Node, "Spot",
        $(go.Shape, "Rectangle", {
            fill: "transparent",
            stroke: "transparent",
            width: 124,
            height: 152,
            portId: "",
            fromLinkable: true,
            toLinkable: true,
            cursor: "pointer",
        }),
        $(go.Panel, "Position",
          $(go.Shape, "RoundedRectangle", {
              fill: "transparent",
              width: 64,
              height: 64,
              position: new go.Point(0, 12),
          }),
          $(go.TextBlock,
              new go.Binding("text", "key"),
              {
              width: 64,
              position: new go.Point(0, 84),
              verticalAlignment: go.Spot.Center,
              textAlign: "center",
              },
          ),
        ),
        $(go.Shape, "Rectangle", {
            fill: null,
            stroke: null,
            desiredSize: new go.Size(0, 0),
            alignment: new go.Spot(0, 0.5, 30, 0),
            portId: "Left",
        }),
        $(go.Shape, "Rectangle", {
            fill: null,
            stroke: null,
            desiredSize: new go.Size(0, 0),
            alignment: new go.Spot(1, 0.5, -30, 0),
            portId: "Right",
        }),
    );

    myDiagram.nodeTemplateMap.add("group-start",
      $(go.Node));

    myDiagram.nodeTemplateMap.add("group-end",
      $(go.Node));

    myDiagram.linkTemplate = $(go.Link,
        //go.Link.Orthogonal,
        {
            relinkableFrom: true,
            relinkableTo: true,
            selectable: true,
            reshapable: true,
            corner: 10,
            toShortLength: 8,
            fromEndSegmentLength: 40,
            toEndSegmentLength: 40,
            routing: go.Link.Orthogonal, //go.Link.AvoidsNodes,
        },
        $(go.Shape, { strokeWidth: 1 }),
        $(go.Shape, { toArrow: "Standard" }),
    );

    myDiagram.groupTemplate = $(go.Group, "Spot",
        {
          layout: new ParallelLDLayout()
        },
        $(go.Panel, "Horizontal",
            $(go.Shape, "Rectangle", {
                width: 30,
                fill: "transparent",
                strokeWidth: 0,
                stretch: go.GraphObject.Fill
            }),
            $(go.Panel, "Auto",
              $(go.Shape, "Rectangle", {
                fill: "transparent",
                strokeWidth: 0,
                stretch: go.GraphObject.Fill
              }),
              $(go.Panel, "Auto", 
                $(go.Shape, "RoundedRectangle", // surrounds everything
                    {
                        parameter1: 10,
                        fill: "white",
                        stroke: '#999999',
                        minSize: new go.Size(240, 220),
                        spot1: go.Spot.TopLeft,
                        spot2:go.Spot.BottomRight
                    }
                ),
                $(go.Panel, "Vertical", // position header above the subgraph
                  $(go.Panel, "Auto", {
                      height: 28,
                      alignment: go.Spot.Left,
                      padding: new go.Margin(8, 12, 0, 12),
                    },
                    $(go.TextBlock, // stage title near top, next to button
                      new go.Binding("text", "key")
                    )
                  ),
                  $(go.Placeholder, // represents area for all member parts
                    {
                        padding: new go.Margin(4, 0, 4, 0),
                        background: "transparent",
                        minSize: new go.Size(240, 160),
                    }),
                  $(go.Panel, "Auto", {
                    height: 28,
                    stretch: go.GraphObject.Fill
                    })
                )
              )
            ),
            $(go.Shape, "Rectangle", {
                width: 30,
                fill: "transparent",
                strokeWidth: 0,
                stretch: go.GraphObject.Fill
            })
        ),
        $(go.Shape, "Rectangle", {
            fill: null,
            stroke: null,
            desiredSize: new go.Size(0, 0),
            alignment: new go.Spot(0, 0.5, 30, 0),
            portId: "Left",
        }),
        $(go.Shape, "Rectangle", {
            fill: null,
            stroke: null,
            desiredSize: new go.Size(0, 0),
            alignment: new go.Spot(1, 0.5, -30, 0),
            portId: "Right",
        }
    ));

    load(0);
}

function load(choice) {
  let nda = [];
  let lda = [];
  if (choice === 0) {    
    nda = [
      {
          key: "Start",
          category: "global",
      },
      {
          key: "End",
          category: "global",
      },
      {
          key: "Group1",
          isGroup: true,
      },
      {
          key: "Group1-start",
          group: "Group1",
          category: "group-start"
      },
      {
          key: "Group1-end",
          group: "Group1",
          category: "group-end"
      },
      {
          key: "Box1",
          group: "Group1",                
      },
      {
          key: "Box2",
          group: "Group1",                
      },
      {
          key: "Box3",
          group: "Group1",                
      },
      {
          key: "Box4",
          group: "Group1",                
      }
    ];
    lda = [
      {
          from: "Start",
          to: "Group1",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Group1",
          to: "End",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Group1-start",
          to: "Box1",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box1",
          to: "Box2",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box2",
          to: "Box3",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box2",
          to: "Box4",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box3",
          to: "Group1-end",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box4",
          to: "Group1-end",
          fromPort: "Right",
          toPort: "Left",
      }
    ];
  } else if (choice === 1) {
    nda = [
      {
          key: "Start",
          category: "global",
      },
      {
          key: "End",
          category: "global",
      },
      {
          key: "Group1",
          isGroup: true,
      },
      {
          key: "Group1-start",
          group: "Group1",
          category: "group-start"
      },
      {
          key: "Group1-end",
          group: "Group1",
          category: "group-end"
      },
      {
          key: "Box1",
          group: "Group1",                
      },
      {
          key: "Box2",
          group: "Group1",                
      },
      {
          key: "Box3",
          group: "Group1",                
      },
      {
          key: "Box4",
          group: "Group1",                
      },
      {
          key: "Box5",
          group: "Group1",                
      },
      {
          key: "Box6",
          group: "Group1",                
      },
      {
          key: "Box7",
          group: "Group1",                
      }
    ];
    lda = [
      {
          from: "Start",
          to: "Group1",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Group1",
          to: "End",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Group1-start",
          to: "Box1",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box1",
          to: "Box2",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box1",
          to: "Box7",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box2",
          to: "Box3",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box2",
          to: "Box6",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box3",
          to: "Box4",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box3",
          to: "Box5",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box4",
          to: "Group1-end",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box5",
          to: "Group1-end",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box6",
          to: "Group1-end",
          fromPort: "Right",
          toPort: "Left",
      },
      {
          from: "Box7",
          to: "Group1-end",
          fromPort: "Right",
          toPort: "Left",
      }
    ];
  }

    const model = new go.GraphLinksModel();
    model.nodeDataArray = nda;
    model.linkDataArray = lda;
    model.linkFromPortIdProperty = "fromPort";
    model.linkToPortIdProperty = "toPort";
    model.nodeGroupKey = "group";
    myDiagram.model = model;
}

window.addEventListener("DOMContentLoaded", init);  

document.getElementById('add').addEventListener('click', e => {
    const groupKey = 'Group1'
    const endKey = `${groupKey}-end`;
    const existingKey = 'Box1';
    const newdata = { group: groupKey };
    myDiagram.model.addNodeData(newdata);
    const newKey = newdata.key;
    myDiagram.model.addLinkData({
        from: existingKey,
        fromPort: "Right",
        to: newKey,
        toPort: "Left",
    });
    myDiagram.model.addLinkData({
        from: newKey,
        fromPort: "Right",
        to: endKey,
        toPort: "Left",
    });        
});   
    </script>
  </body>
</html>

Seems I’ve found the root cause, adding any margins to group nodes seems to mess up how the links reroute in the new GoJs versions.

Yes, in v3 most layouts now observe the Part.margin of the nodes that they lay out in order to treat nodes as bigger or smaller than they actually are. This is easier than setting Layout.boundsComputation.