Node alignment in LayeredDigraphLayout

Hi is there a way to solve the following issue?

We’re using LayeredDigraphLayout and can have a node which can have up to two children that we tried to configure to look like this:

However, in some cases it doesn’t look as expected:

While generally we have this structure, it is possible for loops to exist in our logic (for instance the node on the right could point back to the node of the left or any other node before it), which is why we did not use TreeLayout.

We suspect that it looks like this because of the automatic layout of the nodes which is handled by the LayeredDigraphLayout. Is there a way we can control how the nodes are aligned in relation of each other? Or some other way to fix this?

Here is some example data that should help to reproduce all three cases:

    nodeDataArray = [
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10001,
            iconSrc: "",
            iconColor: "#f2c2f7ff",
            iconBackgroundColor: "#0c0d0dff",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: null,
            key: 10002,
            iconSrc: "",
            iconColor: null,
            iconBackgroundColor: "#1474c3",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10003,
            iconSrc: "",
            iconColor: null,
            iconBackgroundColor: "#1474c3",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10004,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10005,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            key: 10006,
            name: "Name",
            iconSrc: "",
            category: "main",
            nodeType: "OTHERTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10007,
            iconSrc: "",
            iconColor: null,
            iconBackgroundColor: "#1474c3",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: true
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10008,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10009,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10010,
            iconSrc: "",
            iconColor: null,
            iconBackgroundColor: "#1474c3",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10011,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: true
        },
        {
            name: "Name",
            message: null,
            key: 10012,
            iconSrc: "",
            iconColor: null,
            iconBackgroundColor: "#1474c3",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10013,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: true
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10014,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: false
        },
        {
            name: "Name",
            message: "Lorem ipsum lorem ipsum",
            key: 10015,
            iconSrc: "",
            iconColor: "#002137",
            iconBackgroundColor: "#FFFFFF",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: true
        },
        {
            name: null,
            message: null,
            key: 10016,
            iconSrc: "",
            iconColor: null,
            iconBackgroundColor: "#1474c3",
            category: "main",
            nodeType: "MAINTYPE",
            menuIconSrc: "",
            isDecision: true
        },
        {
            key: 10016,
            name: "Name",
            category: "main",
            iconSrc: "",
            iconColor: "#daa4e3ff",
            iconBackgroundColor: "#985aa2ff",
            nodeType: "OTHERTYPE",
            menuIconSrc: "",
            isDecision: true
        }
    ];

    linkDataArray = [
        {
            key: ":10004->10005",
            from: 10004,
            to: 10005,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            category: "normal"
        },
        {
            key: "10005->10006",
            from: 10005,
            to: 10006,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            category: "normal"
        },
        {
            key: "10006->10001",
            from: 10006,
            to: 10001,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            category: "normal"
        },
        {
            key: "10006->10003",
            from: 10006,
            to: 10003,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            category: "normal"
        },
        {
            key: "10006->10002",
            from: 10006,
            to: 10002,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            category: "normal"
        },
        {
            key: "10007->10009",
            from: 10007,
            to: 10009,
            fromSpot: Spot.Top,
            toPort: "leftSide",
            linkTypeText: "No",
            iconSrc: "",
            category: "branching"
        },
        {
            key: "10007->10008",
            from: 10007,
            to: 10008,
            toPort: "leftSide",
            fromSpot: Spot.Bottom,
            linkTypeText: "Yes",
            iconSrc: "",
            category: "branching",
        },
        {
            key: "10011->10013",
            from: 10011,
            to: 10013,
            fromSpot: Spot.Top,
            toPort: "leftSide",
            linkTypeText: "No",
            iconSrc: "",
            category: "branching"
        },
        {
            key: "10011->10015",
            from: 10011,
            to: 10015,
            toPort: "leftSide",
            fromSpot: Spot.Bottom,
            linkTypeText: "Yes",
            iconSrc: "",
            category: "branching",
        },
        {
            key: "10013->10012",
            from: 10013,
            to: 10012,
            fromSpot: Spot.Top,
            toPort: "leftSide",
            linkTypeText: "No",
            iconSrc: "",
            category: "branching"
        },
        {
            key: "10013->10010",
            from: 10013,
            to: 10010,
            toPort: "leftSide",
            fromSpot: Spot.Bottom,
            linkTypeText: "Yes",
            iconSrc: "",
            category: "branching",
        },
        {
            key: "10015->10014",
            from: 10015,
            to: 10014,
            fromSpot: Spot.Top,
            toPort: "leftSide",
            linkTypeText: "No",
            iconSrc: "",
            category: "branching"
        },
        {
            key: "10016->10007",
            from: 10016,
            to: 10007,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            linkTypeText: "some text",
            category: "withText"
        },
        {
            key: "10016->10011",
            from: 10016,
            to: 10011,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            linkTypeText: "some text",
            category: "withText"
        },
        {
            key: "10016->10004",
            from: 10016,
            to: 10004,
            fromSpot: Spot.Right,
            toPort: "leftSide",
            linkTypeText: "some text",
            category: "withText"
        }
    ];

The diagram configuration:

const diagram = $(Diagram, {
            'undoManager.isEnabled': true,
            layout: $(LayeredDigraphLayout, ({
                layerSpacing: 300,
                columnSpacing: 250,
                direction: 0,
                setsPortSpots: false, // do not set the port spots automatically, we do that in the node templates
            })),
            isReadOnly: true,

            model: $(GraphLinksModel, {
                linkKeyProperty: 'key',
                linkFromPortIdProperty: 'fromPort',
                linkToPortIdProperty: 'toPort',
            }),
        });
diagram.toolManager.mouseWheelBehavior = WheelMode.Zoom;

The node template:

const mainTemplate = $(
            Node,
            'Table',
            $(
                Panel,
                'Auto',
                {
                    column: 0,
                    portId: 'leftSide',
                },
                $(
                    Shape,
                    'RoundedRectangle',
                    {
                        isPanelMain: true,
                        strokeWidth: 1,
                        fill: '#ffffff',
                        stroke: '#E6E8EB',
                        margin: 0,
                    },
                    new Binding('width', 'isDecision', (isDecision) => isDecision ? 273 : 320),
                ),
                $(
                    Panel,
                    'Horizontal',
                    {
                        background: 'transparent',
                        margin: 0,
                    },
                    $(
                        Panel,
                        'Table',
                        {
                            background: 'transparent',
                            stretch: Stretch.Vertical,
                            width: 10,
                        },
                    ),
                    $(
                        Panel,
                        'Vertical',
                        {
                            background: 'transparent',
                        },
                        $(
                            Panel,
                            'Horizontal',
                            {
                                background: 'transparent',
                                height: 10,
                                stretch: Stretch.Fill,
                            },
                        ),
                        $(Panel,
                            'Table',
                            $(Panel,
                                'Table',
                                {
                                    row: 0,
                                    margin: new Margin(0, 0, 10, 0)
                                },
                                new Binding('row', 'nodeType', (nodeType) => nodeType === 'MAINTYPE' ? 0 : 1),
                                $(Panel,
                                    'Auto',
                                    {
                                        column: 0,
                                        alignment: Spot.Top,
                                    },
                                    $(Shape, 'Circle', {
                                            fill: 'white',
                                            strokeWidth: 1,
                                            width: 45,
                                            height: 45,
                                            alignment: new Spot(0.5, 0.5),
                                        },
                                        new Binding('fill', 'iconBackgroundColor')
                                    ),
                                    $(Picture,
                                        {
                                            source: '',
                                            width: 25,
                                            height: 25,
                                        },
                                        new Binding('source', 'iconSrc'),
                                    ),
                                ),

                                $(Panel,
                                    'Auto',
                                    {
                                        background: 'transparent',
                                        // width: 240,
                                        column: 1,
                                        margin: new Margin(0, 0, 0, 10),
                                    },
                                    new Binding('width', 'isDecision', (isDecision) => isDecision ? 193 : 240),
                                    $(TextBlock,
                                        {
                                            row: 1,
                                            overflow: TextOverflow.Ellipsis,
                                            maxLines: 2,
                                            font: 'bold 16px Roboto',
                                            margin: new Margin(0, 0, 5, 0),
                                            stretch: Stretch.Fill,
                                            background: 'transparent',
                                            textAlign: 'start',
                                        },
                                        new Binding('text', 'name'),
                                    ),
                                ),
                            ),

                            $(Panel,
                                'Auto',
                                {
                                    row: 1,
                                },
                                $(TextBlock,
                                    {
                                        stretch: Stretch.Fill,
                                        overflow: TextOverflow.Ellipsis,
                                        maxLines: 2,
                                        font: '14px Roboto',
                                        background: 'transparent',
                                        wrap: Wrap.Fit,
                                        textAlign: 'start',
                                    },
                                    [
                                        new Binding('text', 'message'),
                                        new Binding('visible', 'nodeType', (nodeType) => nodeType === 'MAINTYPE'),
                                        new Binding('width', 'isDecision', (isDecision) => isDecision ? 255 : 300),
                                    ]
                                ),
                            ),
                        ),
                        $(
                            Panel,
                            'Horizontal',
                            {
                                background: 'transparent',
                                height: 10,
                                stretch: Stretch.Fill,
                            },
                        )
                    ),
                    $(
                        Panel,
                        'Table',
                        {
                            background: 'transparent',
                            stretch: Stretch.Vertical,
                            width: 10,
                        },
                    ),
                )
            ),
            $(
                Shape,
                'LineH',
                {
                    column: 1,
                    width: 15,
                },
                new Binding('visible', 'isDecision')
            ),
            $(
                Shape,
                'Diamond',
                {
                    column: 2,
                    strokeWidth: 1,
                    fill: '#1474C3',
                    stroke: '#1474C3',
                    margin: 0,
                    width: 32,
                    height: 32,
                    strokeJoin: 'bevel',
                    strokeCap: 'round',
                    portId: '',
                },
                new Binding('visible', 'isDecision')
            ),
        );

diagram.nodeTemplateMap.add('main', mainTemplate);

The link templates:

        const branchingTemplate = $(Link,
            {
                routing: Routing.AvoidsNodes,
                toEndSegmentLength: 50,
                fromEndSegmentLength: 50,
                curve: Curve.JumpOver,
                toSpot: Spot.Left,
            },
            [
                new Binding('fromSpot', 'fromSpot'),
                new Binding('toPort', 'toPort'),
            ],
            $(Shape),
            $(Shape, { toArrow: 'Standard' }),
            $(Panel, 'Auto').add(
                $(Shape, 'RoundedRectangle',
                    {
                        stroke: 'transparent',
                        fill: '#E6E8EB',
                    },
                    new Binding('width', 'linkTypeText', (text) => text.length * 12 + 16),
                ),
                $(Panel, 'Horizontal').add(
                    $(Picture,
                        { source: '', width: 16, height: 16 },
                        new Binding('source', 'linkIconSrc'),
                    ),
                    $(TextBlock, {
                        verticalAlignment: Spot.Center,
                        font: '12px Roboto',
                    }).bind('text','linkTypeText')
                ),

            )
        );

        const withTextTemplate = $(Link,
            {
                routing: Routing.AvoidsNodes,
                toEndSegmentLength: 40,
                curve: Curve.JumpOver,
                toSpot: Spot.Left,
            },
            [
                new Binding('fromSpot', 'fromSpot'),
            ],
            $(Shape),
            $(Shape, { toArrow: 'Standard' }),
            $(Panel, 'Auto',
                {
                    segmentIndex: -2,
                    segmentFraction: 0.3,
                }
            ).add(
                $(Shape, 'RoundedRectangle',
                    {
                        stroke: 'transparent',
                        fill: '#E6E8EB',
                    },
                ),
                $(Panel, 'Horizontal').add(
                    $(TextBlock, {
                        overflow: TextOverflow.Ellipsis,
                        wrap: Wrap.Fit,
                        maxLines: 1,
                        verticalAlignment: Spot.Center,
                        font: '12px Roboto',
                        width: 150,
                    }).bind('text','linkTypeText')
                ),

            )
        );

        const normalLinkTemplate = $(Link,
            {
                routing: Routing.AvoidsNodes,
                toEndSegmentLength: 40,
                curve: Curve.JumpOver,
                fromSpot: Spot.Right,
                toSpot: Spot.Left,
            },
            [
                new Binding('toSpot', 'toSpot'),
            ],
            $(Shape),
            $(Shape, { toArrow: 'Standard' }),
        );

        diagram.linkTemplateMap.add('branching', branchingTemplate);
        diagram.linkTemplateMap.add('withText', withTextTemplate);
        diagram.linkTemplateMap.add('normal', normalLinkTemplate);

Thanks for providing the link template. It appears that via data binding you are forcing the Link.fromSpot and Link.toSpot to be particular values. Is there a reason that you cannot let the normal routing choose the appropriate middle side point for a link connection? You might want to set LayeredDigraphLayout.setsPortSpots to false.

However, that might cause multiple links to come out at the same point (on the right side), and maybe you don’t want that. I think it would be best if you customized the layout to automatically compute the desired Link.fromSpot and toSpot to use depending on the number of “children” and their relative position(s) with the “parent” node.

On the other hand, maybe using TreeLayout would work OK if you used AvoidsNodes Link.routing on those occasional links that cycled back.

Thank you for your answer!

The reason the spots are set manually is because I want the links to come out from the top and bottom of the diamond depending on my internal logic. (I always want the No labeled link to come out from the top and the Yes labeled one to come out from the bottom)

I did give the TreeLayout another try and it actually seems to be working for all cases mentioned except the one where only one link comes out from the diamond.

Is there a way to manually place the second node on the screenshot higher so that the link doesn’t have to make extra turns?

Not much of a choice in that case. Why not allow that “No” link to come out of the right side, so that everything goes straight through?

Both TreeLayout and LayeredDigraphLayout try to have single "child"ren placed directly in line with the “parent” node. You can make the layout pretend that there are actually two (or more) children for a parent node that actuallly only has one child node by overriding makeNetwork to insert dummy vertexes and edges into the network that the layout operates on. If you want that, I can show you how to do that.

OK, here’s an example customization of TreeLayout.assignTreeVertexValues to make a parent of a single child be offset from the child.

You will probably need to fiddle with how you compute the TreeVertex.height.

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">
class OffsetSingleChildTreeLayout extends go.TreeLayout {
  constructor(init) {
    super();
    if (init) Object.assign(this, init);
  }

  assignTreeVertexValues(v) {
    if (v.childrenCount === 1) {
      v.alignment = go.TreeAlignment.CenterChildren;
      const c = v.children[0];
      if (this.angle === 0 || this.angle === 180) {
        c.height = Math.max(c.height, v.height) * 2 + 50;
        c.focusY = c.height/2;
      } else {
        c.width = Math.max(c.width, v.width) * 2 + 50;
        c.focusX = c.width/2;
      }
    }
  }
}

const myDiagram =
  new go.Diagram("myDiagramDiv", {
      layout: new OffsetSingleChildTreeLayout({ nodeSpacing: 0 }),
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ margin: 8 })
        .bind("text")
    );

myDiagram.linkTemplate =
  new go.Link({ routing: go.Link.Orthogonal, corner: 5 })
    .add(
      new go.Shape()
    );

myDiagram.model = new go.GraphLinksModel(
[
  { key: 1, text: "Alpha", color: "lightblue" },
  { key: 2, text: "Beta", color: "orange" },
  { key: 3, text: "Gamma", color: "lightgreen" },
  { key: 4, text: "Delta", color: "pink" },
  { key: 5, text: "Epsilon", color: "yellow" },
  { key: 6, text: "Zeta", color: "coral" },
  { key: 7, text: "Eta", color: "tomato" },
  { key: 8, text: "Theta", color: "goldenrod" },
  { key: 9, text: "Iota", color: "magenta" },
  { key: 10, text: "Kappa", color: "cyan" },
  { key: 11, text: "Lambda", color: "lime" },
],
[
  { from: 1, to: 2 },
  { from: 1, to: 3 },
  { from: 1, to: 4 },
  { from: 2, to: 5 },
  { from: 2, to: 6 },
  { from: 3, to: 7 },
  { from: 4, to: 8 },
  { from: 4, to: 9 },
  { from: 6, to: 10 },
  { from: 8, to: 11 },
]);
  </script>
</body>
</html>

Thanks a lot for providing this example. I’ll need to fiddle with it, but seems to be working well.