Vertically center the start and the end nodes inside a group

If a group has a simple diagram, the start node and the end node (red dots) are vertically-center aligned inside a group, which is good because the group ports are also vertically centered.

However, when the diagram inside a group becomes a little more complicated, the start and the end nodes are no longer vertically-center aligned.


I am wondering if there is any approach to always keep the start and the end nodes inside a group vertically-center aligned, which makes the start node and the left port properly aligned and the end node and the right port properly aligned.

I posted the HTML + JS code below with the problematic layout.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #myDiagramDiv {
        position: absolute;
        top: 50px;
        bottom: 50px;
        left: 0;
        right: 0;
        border: 1px solid black;
      }
    </style>
  </head>
  <body>
    <div id="myDiagramDiv"></div>
    <script src="../../release/go-debug.js"></script>
    <script>
      const $ = go.GraphObject.make;

      const init = () => {
        const myDiagram = $(go.Diagram, "myDiagramDiv", {
          layout: $(go.LayeredDigraphLayout, {
            layerSpacing: 50,
            linkSpacing: 20,
            layeringOption: go.LayeredDigraphLayout.LayerLongestPathSource,
          }),
        });

        myDiagram.nodeTemplateMap.add(
          "",
          $(
            go.Node,
            "Position",
            $(go.Shape, "RoundedRectangle", {
              name: "CONTAINER",
              fill: "transparent",
              width: 200,
              height: 150,
              stroke: null,
            }),
            $(go.Shape, "RoundedRectangle", {
              fill: "white",
              position: new go.Point(0, 0),
              width: 200,
              height: 120,
            }),
            $(
              go.TextBlock, // the text label
              new go.Binding("text", "label"),
              {
                position: new go.Point(0, 120),
                width: 200,
                height: 30,
                verticalAlignment: go.Spot.Center,
                textAlign: "center",
              },
            ),
          ),
        );
        myDiagram.nodeTemplateMap.add(
          "global-node",
          $(
            go.Node,
            "Spot",
            $(go.Shape, "Circle", {
              fill: null,
              width: 30,
              height: 30,
              stroke: "black",
            }),
          ),
        );
        myDiagram.nodeTemplateMap.add(
          "group-node",
          $(
            go.Node,
            "Spot",
            $(go.Shape, "Circle", {
              fill: "red",
              width: 5,
              height: 5,
              stroke: null,
            }),
          ),
        );

        myDiagram.linkTemplate = $(
          go.Link,
          go.Link.Orthogonal,
          {
            corner: 10,
          },
          $(go.Shape, { strokeWidth: 1 }),
          $(go.Shape, { toArrow: "Standard", stroke: null }),
        );

        myDiagram.groupTemplate = $(
          go.Group,
          "Auto",
          {
            layout: $(go.LayeredDigraphLayout, {
              layeringOption: go.LayeredDigraphLayout.LayerLongestPathSource,
              layerSpacing: 50,
            }),
          },
          $(
            go.Shape,
            "RoundedRectangle", // surrounds everything
            { parameter1: 10, fill: "rgba(128,128,128,0.33)" },
          ),
          $(
            go.Panel,
            "Vertical", // position header above the subgraph
            $(
              go.Panel,
              {
                height: 20,
              },
              $(
                go.TextBlock, // group title near top, next to button
                { font: "Bold 12pt Sans-Serif" },
                new go.Binding("text", "label"),
              ),
            ),
            $(
              go.Placeholder, // represents area for all member parts
              { padding: 5, background: "white" },
            ),
            $(go.Panel, {
              height: 20,
            }),
          ),
        );

        const nodeDataArray = [
          {
            key: "start",
            category: "global-node",
          },
          {
            key: "end",
            category: "global-node",
          },
          {
            key: "group",
            isGroup: true,
            label: "Group",
          },
          {
            key: "group-start",
            group: "group",
            category: "group-node",
          },
          {
            key: "group-end",
            group: "group",
            category: "group-node",
          },
          {
            key: "node1",
            group: "group",
            label: "Node 1",
          },
          {
            key: "node2",
            group: "group",
            label: "Node 2",
          },
          {
            key: "node3",
            group: "group",
            label: "Node 3",
          },
        ];
        const linkDataArray = [
          {
            from: "start",
            to: "group",
          },
          {
            from: "group",
            to: "end",
          },
          {
            from: "group-start",
            to: "node1",
          },
          {
            from: "group-start",
            to: "node2",
          },
          {
            from: "node1",
            to: "node3",
          },
          {
            from: "node2",
            to: "group-end",
          },
          {
            from: "node3",
            to: "group-end",
          },
        ];

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

      window.addEventListener("DOMContentLoaded", init);
    </script>
  </body>
</html>

Below are another nodeDataArray and linkDataArray that cause problematic layout.

const nodeDataArray = [
  {
    key: "start",
    category: "global-node",
  },
  {
    key: "end",
    category: "global-node",
  },
  {
    key: "group",
    isGroup: true,
    label: "Group",
  },
  {
    key: "group-start",
    group: "group",
    category: "group-node",
  },
  {
    key: "group-end",
    group: "group",
    category: "group-node",
  },
  {
    key: "node1",
    group: "group",
    label: "Node 1",
  },
  {
    key: "node2",
    group: "group",
    label: "Node 2",
  },
  {
    key: "node3",
    group: "group",
    label: "Node 3",
  },
];
const linkDataArray = [
  {
    from: "start",
    to: "group",
  },
  {
    from: "group",
    to: "end",
  },
  {
    from: "group-start",
    to: "node1",
  },
  {
    from: "group-start",
    to: "node2",
  },
  {
    from: "node1",
    to: "node3",
  },
  {
    from: "node3",
    to: "group-end",
  },
];

Thanks so much for your time!

First, I suggest that you use GoJS version 2.3 and then set LayeredDigraphLayout.alignOption to go.LayeredDigraphLayout.AlignAll.

Second, I suggest that you customize the layout so that it explicitly positions the start and end nodes vertically where you want them to be. Define a subclass and override the LayeredDigraphLayout.commitLayout method to call the super method and then position those start and end nodes. An example is at: Minimal GoJS Sample

Thank you Walter. The given example provides a promising approach. Let me try it.

Hi Walter,

I tried your suggestion (I only applied the custom layout in the group template). It fixed some problematic cases. However, if I made the diagram more complicated, I noticed a link routing issue below. Do you know how to fix it?

Below is the diagram if I am not using the custom layout and not using alignOption: go.LayeredDigraphLayout.AlignAll.

I posted the HTML + JS code below. I am using the latest version of 2.3.4. Thanks!

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #myDiagramDiv {
        position: absolute;
        top: 50px;
        bottom: 50px;
        left: 0;
        right: 0;
        border: 1px solid black;
      }
    </style>
  </head>
  <body>
    <div id="myDiagramDiv"></div>
    <script src="../../release/go-debug.js"></script>
    <script>
      // https://gojs.net/extras/InputOutputLayeredDigraph.html
      // Assume each layout includes at most one "Inputs" node and at most one "Outputs" node,
      // and that they are (respectively) an ultimate source and an ultimate sink for all of the other nodes.
      // In other words, this assumes there are no other nodes in any layer with an "Input" or an "Output" node.
      class InputOutputLayeredDigraphLayout extends go.LayeredDigraphLayout {
        constructor() {
          super();
          //   this.alignOption = go.LayeredDigraphLayout.AlignAll;
        }

        commitLayout() {
          super.commitLayout();
          var bounds = this.diagram.computePartsBounds(
            this.network.findAllParts(),
            false,
          );
          this.network.vertexes.each(v => {
            var n = v.node;
            if (n === null) return;
            // assume there's at most one such node in each category
            if (n.category === "group-node") {
              // center it vertically
              n.moveTo(n.position.x, bounds.centerY - n.actualBounds.width / 2);
            }
          });
        }
      }
      // end of InputOutputLayeredDigraphLayout

      const $ = go.GraphObject.make;

      const init = () => {
        const myDiagram = $(go.Diagram, "myDiagramDiv", {
          layout: $(go.LayeredDigraphLayout, {
            layerSpacing: 50,
            linkSpacing: 20,
            layeringOption: go.LayeredDigraphLayout.LayerLongestPathSource,
            alignOption: go.LayeredDigraphLayout.AlignAll,
          }),
        });

        myDiagram.nodeTemplateMap.add(
          "",
          $(
            go.Node,
            "Position",
            $(go.Shape, "RoundedRectangle", {
              name: "CONTAINER",
              fill: "transparent",
              width: 200,
              height: 150,
              stroke: null,
            }),
            $(go.Shape, "RoundedRectangle", {
              fill: "white",
              position: new go.Point(0, 0),
              width: 200,
              height: 120,
            }),
            $(
              go.TextBlock, // the text label
              new go.Binding("text", "label"),
              {
                position: new go.Point(0, 120),
                width: 200,
                height: 30,
                verticalAlignment: go.Spot.Center,
                textAlign: "center",
              },
            ),
          ),
        );
        myDiagram.nodeTemplateMap.add(
          "global-node",
          $(
            go.Node,
            "Spot",
            $(go.Shape, "Circle", {
              fill: null,
              width: 30,
              height: 30,
              stroke: "black",
            }),
          ),
        );
        myDiagram.nodeTemplateMap.add(
          "group-node",
          $(
            go.Node,
            "Spot",
            $(go.Shape, "Circle", {
              fill: "red",
              width: 5,
              height: 5,
              stroke: null,
            }),
          ),
        );

        myDiagram.linkTemplate = $(
          go.Link,
          go.Link.Orthogonal,
          {
            corner: 10,
          },
          $(go.Shape, { strokeWidth: 1 }),
          $(go.Shape, { toArrow: "Standard", stroke: null }),
        );

        myDiagram.groupTemplate = $(
          go.Group,
          "Auto",
          {
            layout: $(InputOutputLayeredDigraphLayout, {
              layeringOption: go.LayeredDigraphLayout.LayerLongestPathSource,
              layerSpacing: 50,
              alignOption: go.LayeredDigraphLayout.AlignAll,
            }),
          },
          $(
            go.Shape,
            "RoundedRectangle", // surrounds everything
            { parameter1: 10, fill: "rgba(128,128,128,0.33)" },
          ),
          $(
            go.Panel,
            "Vertical", // position header above the subgraph
            $(
              go.Panel,
              {
                height: 20,
              },
              $(
                go.TextBlock, // group title near top, next to button
                { font: "Bold 12pt Sans-Serif" },
                new go.Binding("text", "label"),
              ),
            ),
            $(
              go.Placeholder, // represents area for all member parts
              { padding: 5, background: "white" },
            ),
            $(go.Panel, {
              height: 20,
            }),
          ),
        );

        const nodeDataArray = [
          {
            key: "start",
            category: "global-node",
          },
          {
            key: "end",
            category: "global-node",
          },
          {
            key: "group",
            isGroup: true,
            label: "Group",
          },
          {
            key: "group-start",
            group: "group",
            category: "group-node",
          },
          {
            key: "group-end",
            group: "group",
            category: "group-node",
          },
          {
            key: "node1",
            group: "group",
            label: "Node 1",
          },
          {
            key: "node2",
            group: "group",
            label: "Node 2",
          },
          {
            key: "node3",
            group: "group",
            label: "Node 3",
          },
          {
            key: "node4",
            group: "group",
            label: "Node 4",
          },
          {
            key: "node5",
            group: "group",
            label: "Node 5",
          },
          {
            key: "node6",
            group: "group",
            label: "Node 6",
          },
        ];
        const linkDataArray = [
          {
            from: "start",
            to: "group",
          },
          {
            from: "group",
            to: "end",
          },
          {
            from: "group-start",
            to: "node1",
          },
          {
            from: "node1",
            to: "node2",
          },
          {
            from: "node1",
            to: "node3",
          },
          {
            from: "node2",
            to: "node4",
          },
          {
            from: "node2",
            to: "node5",
          },
          {
            from: "node2",
            to: "node6",
          },
          {
            from: "node3",
            to: "group-end",
          },
          {
            from: "node4",
            to: "group-end",
          },
          {
            from: "node5",
            to: "group-end",
          },
          {
            from: "node6",
            to: "group-end",
          },
        ];

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

      window.addEventListener("DOMContentLoaded", init);
    </script>
  </body>
</html>

Hi Walter,

link’s routing: go.Link.AvoidsNodes does not fix the issue :(

This is what I tried:

myDiagram.linkTemplate = $(
          go.Link,
          go.Link.Orthogonal,
          {
            corner: 10,
            routing: go.Link.AvoidsNodes,
          },
          $(go.Shape, { strokeWidth: 1 }),
          $(go.Shape, { toArrow: "Standard", stroke: null }),
        );

Thanks,
Min

Yes, if I understand your complaint, the problem wasn’t a matter of a link path crossing any node – it was that the routing didn’t go the way that you wanted.

I think you want something like Parallel Layout, where the ParallelLayout extension makes sure to route the links to the merge node (your end node) so that the paths do not cross over any nodes (i.e. AvoidsNodes routing isn’t required – it’s only expecting Orthogonal routing).

That’s implemented in the extension: https://gojs.net/latest/extensions/ParallelLayout.js in the commitLinks method override (although you can do it in your commitLayout override too. Note the call to Link.updateRoute to force the route to be updated early so that the code can examine and modify the routes of the links to nodes that have moved. Note also that that code also changes the fromSpot and toSpot appropriately for links that loop back, but that might not be a concern for you if your graphs are acyclic.

Hi Walter,

If I understand your suggestion correctly, the solution is to re-route the links to the end node (by calling link.updateRoute()) after the end node is moved.

I tried the following two approaches but either of them works :(

The first approach is to put link.updateRoute() inside commitLayout after the end node is moved.

commitLayout() {
          super.commitLayout();
          var bounds = this.diagram.computePartsBounds(
            this.network.findAllParts(),
            false,
          );
          this.network.vertexes.each(v => {
            var n = v.node;
            if (n === null) return;
            // assume there's at most one such node in each category
            if (n.category === "group-node") {
              // center it vertically
              n.moveTo(n.position.x, bounds.centerY - n.actualBounds.width / 2);

              var it = n.findLinksInto();
              while (it.next()) {
                var link = it.value;
                link.updateRoute();
              }
            }
          });
        }

And the second approach is to override commitLinks, which is called after commitLayout (where the end node is moved)

        commitLayout() {
          super.commitLayout();
          var bounds = this.diagram.computePartsBounds(
            this.network.findAllParts(),
            false,
          );
          this.network.vertexes.each(v => {
            var n = v.node;
            if (n === null) return;
            // assume there's at most one such node in each category
            if (n.category === "group-node") {
              // center it vertically
              n.moveTo(n.position.x, bounds.centerY - n.actualBounds.width / 2);
            }
          });
        }

        commitLinks() {
          this.network.edges.each(e => {
            var l = e.link;
            if (l && l.toNode.category === "group-node") {
              l.updateRoute();
            }
          });
        }

Do I misunderstand your suggestion?

One thing to note is that in this specific example, alignOption: go.LayeredDigraphLayout.AlignAll already moves the start node and the end node to their correct position (vertically centered), which means n.moveTo(n.position.x, bounds.centerY - n.actualBounds.width / 2); inside commitLayout does not change anything. What I mean is that the routing issue is actually caused by AlignAll.

Thanks for your time!

You have the suggestion half-right. For each Link, once you have called updateRoute, the Link.points will be valid. But if the route is not to your satisfaction, you can modify it by computing a new List of Points. That is demonstrated by the second sample, the ParallelLayout. Note the code checking links coming into the merge/end node, after checking that the routing is orthogonal.

Hi Walter, I am trying to generate a new List of Points to re-route the problematic links. I remember that GoJS has its own way to generate link points. Could you please send me the reference link? For example, inside the commitLinks function in ParallelLayout.js, I saw

if (link.pointsCount >= 6) 
var p2 = pts.elt(pts.length - 4);
var p3 = pts.elt(pts.length - 3);

What is the specific reason for >=6, -4, and -3? I noticed that the problematic links have 8 points, where as the normal links have 6 points. I am curious why it happens. Thanks!

If the routing is orthogonal, there should be an even number of points in any valid route. In the normal, typical case there will be 6 points, but there could well be more if the routing is AvoidsNodes.

Since the indexing of the points is zero-based, the last point of a route of length 6 would be point # 5. And as the naming of the local variables suggests, the point at index length-4 would typically be the point at index 2.

Thank you, Walter! Your proposal works well :)