Which layout would best support my use case

I am currently using go.TreeLayout (React). The goal is to be able to construct a document template from a palette of available section names as so:


so the document would look like:

# Introduction (read-only/noneditable)
## Intro Pt. 1
# Next Steps

The pink node instructs a section. The blue node is a main section. The lighter blue node is a child section of a main section.

I currently only have the “drop here” nodes, and main section nodes working fine. The issue lies with adding the child node or pink node. It appears as so:

If TreeLayout would not be the best approach to accomplish this, which other layout would be recommended?
If this can be accomplished in a tree layout, what would be the correct approach?

There isn’t any existing layout that does exactly what you want. However, I’ll give you a custom layout that does something similar to what you want. A result from using DirectionalLayout is:


Note that the actual appearance of each node doesn’t really matter – it just needs four ports.

Note also that one significant difference between this and what you want is that links would be directional going from the blue “Introduction” node to the pink “ReadOnly” node. You could reverse the apparent direction by turning off the toArrow and adding a fromArrow.

<!DOCTYPE html>
<head>
  <title>GoJS Custom Layout Sample</title>
  <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->
  <meta name="description" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <script src="go.js"></script>
  <script id="code">
    // Assume the graph can be walked like a tree, although this is tolerant of cycles.
    // The fromSpot of a link's fromPort determines the direction that the connected node will be placed.
    //??? Note that nodes can overlap each other, when they are placed in different subtrees that
    // converge on the same area, e.g. right-then-down and down-then-right.
    class DirectionalLayout extends go.Layout {
      constructor() {
        super();
        this.siblingSpacing = 10;  // parameters for spacing between nodes
        this.layerSpacing = 50;
      }

      spotToAngle(spot) {
        if (spot.x === 1) return 0;
        if (spot.y === 1) return 90;
        if (spot.x === 0) return 180;
        if (spot.y === 0) return 270;
        return 0;
      }

      doLayout(coll) {
        if (this.network === null) {
          this.network = this.makeNetwork(coll);
        }
        this.arrangementOrigin = this.initialOrigin(this.arrangementOrigin);

        var it = this.network.vertexes.iterator;
        while (it.next()) {
          var v = it.value;
          v.isPositioned = false;
          v.angle = 0;
          var n = v.node;
          if (n !== null) {
            var l = n.findTreeParentLink();
            var p = n.findTreeParentNode();
            if (l !== null && p !== null) {
              // remember the angle at which this vertex should be placed relative to its parent
              //?? this assumes the direction can be taken from the port's spot
              var spot = l.fromPort.part === p ? l.fromPort.fromSpot : l.toPort.toSpot;
              v.angle = this.spotToAngle(spot);
            }
          }
        }

        var x = this.arrangementOrigin.x;
        var y = this.arrangementOrigin.y;
        var root = null;
        while (root = this.findRoot()) {
          //?? this assumes separate trees get arranged vertically above each other
          if (y !== this.arrangementOrigin.y) y += root.height / 2;
          // position root and then walk the tree starting at that vertex
          root.centerX = x;
          root.centerY = y;
          root.isPositioned = true;
          this.walkTree(root);
          // determine position of next subtree
          var maxy = this.arrangementOrigin.y;
          this.network.vertexes.each(function (v) {
            if (v.isPositioned) maxy = Math.max(maxy, v.bounds.bottom);
          })
          y = maxy + this.layerSpacing;
        }

        this.updateParts();
        this.network = null;
      }

      findRoot() {
        var root = null;
        // find a root vertex -- one without any incoming edges
        var it = this.network.vertexes.iterator;
        while (it.next()) {
          var v = it.value;
          if (v.isPositioned) continue;  // skip any already-positioned nodes
          if (root === null) root = v;  // in case there are only cycles
          if (v.sourceEdges.count === 0) {
            root = v;  // found a plausible root
            break;
          }
        }
        return root;
      }

      walkTree(v) {
        // group child vertexes by angle
        var rights = [];
        var bottoms = [];
        var lefts = [];
        var tops = [];
        var it = v.destinationVertexes.iterator;
        while (it.next()) {
          var child = it.value;
          if (child.isPositioned) continue;  // ignore already positioned vertexes
          if (child.angle === 0) rights.push(child);
          else if (child.angle === 90) bottoms.push(child);
          else if (child.angle === 180) lefts.push(child);
          else if (child.angle === 270) tops.push(child);
        }
        // now position them and walk them recursively
        this.positionChildren(rights, v, 0);
        this.positionChildren(bottoms, v, 90);
        this.positionChildren(lefts, v, 180);
        this.positionChildren(tops, v, 270);
      }

      positionChildren(arr, parent, angle) {
        if (arr.length === 0) return;
        var cx = parent.centerX;
        var cy = parent.centerY;
        if (arr.length === 1) {
          var main = arr[0];
          // just center the node relative to the parent
          this.positionChild(parent, main, angle, cx, cy);
        } else {
          //??? Choose which child to be aligned with the parent, could be marked on the node data.
          // Maybe sort the children too.
          var mainidx = Math.floor(arr.length / 2);  // for now, just pick a middle one
          var main = arr[mainidx];

          var horiz = (angle === 0 || angle === 180);
          this.positionChild(parent, main, angle, cx, cy);

          // position siblings above main vertex
          var lastchild = main;
          for (var i = mainidx - 1; i >= 0; i--) {
            var child = arr[i];
            if (horiz) {
              cy -= lastchild.height / 2 + this.siblingSpacing + child.height / 2;
            } else {
              cx -= lastchild.width / 2 + this.siblingSpacing + child.width / 2;
            }
            lastchild = child;
            this.positionChild(parent, child, angle, cx, cy);
          }

          // position siblings below main vertex
          cx = parent.centerX;
          cy = parent.centerY;
          lastchild = main;
          for (var i = mainidx + 1; i < arr.length; i++) {
            var child = arr[i];
            if (horiz) {
              cy += lastchild.height / 2 + this.siblingSpacing + child.height / 2;
            } else {
              cx += lastchild.width / 2 + this.siblingSpacing + child.width / 2;
            }
            lastchild = child;
            this.positionChild(parent, child, angle, cx, cy);
          }
        }
      }

      positionChild(parent, child, angle, cx, cy) {
        // add space between layers
        switch (angle) {
          case 0: cx += (parent.width / 2 + this.layerSpacing + child.width / 2); break;
          case 180: cx -= (parent.width / 2 + this.layerSpacing + child.width / 2); break;
          case 90: cy += (parent.height / 2 + this.layerSpacing + child.height / 2); break;
          case 270: cy -= (parent.height / 2 + this.layerSpacing + child.height / 2); break;
        }
        child.centerX = cx;  // position the center of the vertex
        child.centerY = cy;
        child.isPositioned = true;  // mark so that it won't be positioned/walked again
        this.walkTree(child);  // recurse
      }
    }  // end of DirectionalLayout


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

      myDiagram =
        new go.Diagram("myDiagramDiv",
          {
            initialContentAlignment: go.Spot.Center,  // for v1.*
            layout: $(DirectionalLayout),
            "undoManager.isEnabled": true,
            "ModelChanged": function (e) {     // just for demonstration purposes,
              if (e.isTransactionFinished) {  // show the model data in the page's TextArea
                document.getElementById("mySavedModel").textContent = e.model.toJson();
              }
            }
          });

      function makePort(name, spot) {
        return $(go.Shape,
          { fill: "white", width: 7, height: 7, alignment: spot, alignmentFocus: spot },
          { portId: name, fromSpot: spot, toSpot: spot });  // fromSpot is needed by DirectionalLayout
      }

      myDiagram.nodeTemplate =
        $(go.Node, "Spot",
          { locationSpot: go.Spot.Center, selectionObjectName: "BODY" },
          { toolTip: $("ToolTip", $(go.TextBlock, new go.Binding("text", "info"))) },
          $(go.Panel, "Auto",
            { name: "BODY", width: 70, height: 70 },
            $(go.Shape, { fill: "white" },
              new go.Binding("fill", "color")),
            $(go.TextBlock, { name: "TEXT", margin: 7 },
              new go.Binding("text"))
          ),
          makePort("t", go.Spot.Top),
          makePort("r", go.Spot.Right),
          makePort("b", go.Spot.Bottom),
          makePort("l", go.Spot.Left),
          $("Button", { alignment: go.Spot.TopRight, alignmentFocus: go.Spot.TopRight },
            $(go.TextBlock, "+", { name: "TB" }),
            {
              click: function (e, button) {
                var node = button.part;
                var body = node.findObject("BODY");
                node.diagram.commit(function (diagram) {
                  if (body.width === 70) {
                    body.width = 150;
                    body.height = 120;
                    button.findObject("TB").text = "-";
                  } else {
                    body.width = 70;
                    body.height = 70;
                    button.findObject("TB").text = "+";
                  }
                }, "toggled node size");
              }
            }
          )
        );

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

      myDiagram.model = $(go.GraphLinksModel,
        {
          linkFromPortIdProperty: "fp",
          linkToPortIdProperty: "tp",
          nodeDataArray:
            [
              { 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: 11, text: "End 1" },
              { key: 12, text: "End 2" },
              { key: 13, text: "End 3" },
              { key: 14, text: "End 4" },
              { key: 21, text: "right" },
              { key: 22, text: "right" },
              { key: 23, text: "down" },
              { key: 24, text: "down" },
              { key: 242, text: "down" },
              { key: 243, text: "right" },
              { key: 25, text: "left" },
              { key: 26, text: "left" },
            ],
          linkDataArray:
            [
              { from: 1, to: 2, fp: "r", tp: "l" },
              { from: 2, to: 3, fp: "r", tp: "l" },
              { from: 2, to: 4, fp: "b", tp: "t" },
              { from: 4, to: 5, fp: "b", tp: "t" },
              { from: 3, to: 11, fp: "r", tp: "l" },
              { from: 3, to: 12, fp: "r", tp: "l" },
              { from: 3, to: 13, fp: "r", tp: "l" },
              { from: 3, to: 14, fp: "r", tp: "l" },
              { from: 5, to: 21, fp: "r", tp: "l" },
              { from: 21, to: 22, fp: "r", tp: "l" },
              { from: 5, to: 23, fp: "b", tp: "t" },
              { from: 23, to: 24, fp: "b", tp: "t" },
              { from: 23, to: 242, fp: "b", tp: "t" },
              { from: 23, to: 243, fp: "r", tp: "l" },
              { from: 5, to: 25, fp: "l", tp: "r" },
              { from: 25, to: 26, fp: "l", tp: "r" },
            ]
        });
    }
  </script>
</head>

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

</html>

Hey, Walter! Appreciate the quick response. After some tweaks to the custom layout, I now understand how to make this fit for my requirements. Thank you so much for your continuous support in these forums!