Node layout in banded LayeredDigraphLayout

Dear Support,

I am using a custom LayeredDigraphLayout that knows about bands and that positions each node in its respective band, which was provided by Walter during my GoJS evaluation period a couple of months ago. (BandedLayeredDigraphLayout)

Now I am using an adapted angular version of the provided layout BandedLayeredDigraphLayout.

Although the layout very much meets our needs, there is one major issue, which i - so far - could not adequately address.

In the image below is an example of a diagram that shows a history of data objects (version number is shown in the node):

As can be seen, the nodes favor a left-sided layout in each band.
This leads to links not going straight down, but going to the left.
One example are the outgoing connections from version 1478.
With this behavior, it becomes difficult to determine which nodes are connected to 1478.

In order to increase visibility, I would like to spread the connections more into the horizontal direction with connections favoring straight lines, when possible.

The desired outcome should look something like that:

I already played around a bit with changing the values of the property columnSpacing as well as layerSpacing, but that did not have the desired effect, since the nodes are only spread out in general, while not changing the general layout behavior.

Could you please help in this regard?

This is the layout I use:

import go from 'gojs';

/**
 * A custom LayeredDigraphLayout that knows about "bands"
 * and that positions each node in its respective band.
 *
 * @category Layout Extension
 */

export interface BandDescriptor extends go.ObjectData {
  text: string;
  creationWeek: number;
  creationYear: number;
  start: number;
  depth: number;
}

// NOTE: "Swimlane" layouts have the natural flow and growth of the layout go in the direction of the lanes.
// Nodes need to be annotated to indicate which lane they should be in.
// "Banded" layouts have the bands around layers of nodes that are laid out perpendicular to the natural flow.
// Nodes need to be annotated to indicate which band they should be in.

// Perform a LayeredDigraphLayout where each node is assigned to a "band", each of which may consist of multiple layers.
// LayeredDigraphLayout.commitLayers is overridden to modify the background Part whose key is "_BANDS".
// It is assumed that the _BANDS's data object's "descriptors" property will hold an Array of Objects, each describing a band.
// This Array determines the order of the bands, not the precedence order given by the nodes and links
// combined with the "band" property on each node data object identifying which band the node must be in.
// The node data "band" value should match one of the band descriptor's "text" property in the _BANDS descriptors.
// If the "band" property is not present or if it names a non-existent band, there will be a console warning,
// and the layout results might look odd.
export class BandedLayeredDigraphLayout extends go.LayeredDigraphLayout {
  private bandNames: go.Map<string, any> = new go.Map();

  private collectVertexesForBands(): go.Map<string, go.Set<go.LayeredDigraphVertex>> {
    const bandVertexes = new go.Map<string, go.Set<go.LayeredDigraphVertex>>();

    const vertexIterator = this.network.vertexes.iterator;
    while (vertexIterator.next()) {
      const vertex: go.LayeredDigraphVertex = vertexIterator.value as go.LayeredDigraphVertex;

      if (vertex.node === null || vertex.node.data === null) {
        continue;
      }

      const band = vertex.node.data.bandName;
      if (!this.bandNames.get(band)) {
        console.log(
          'BandedLayeredDigraphLayout: unknown band name on node data: '
          + band + ' on node of key: ' + vertex.node.key
        );
      }

      if (!bandVertexes.get(band)) {
        bandVertexes.set(band, new go.Set<go.LayeredDigraphVertex>());
      }

      bandVertexes.get(band).add(vertex);
    }

    return bandVertexes;
  }

  private walkAllWithinBand(name: string, vertex: go.LayeredDigraphVertex, depths: go.Map<go.LayeredDigraphVertex, number>): number {
    if (depths.has(vertex)) {
      return depths.get(vertex);
    }

    let depth = 0;
    vertex.sourceVertexes.each(sourceVertex => {
      if (!sourceVertex.node || !sourceVertex.node.data || sourceVertex.node.data.bandName !== name) {
        // ignore vertexes of different band
        return;
      }

      depth = Math.max(depth, this.walkAllWithinBand(name, sourceVertex as go.LayeredDigraphVertex, depths));
    });

    depths.set(vertex, depth + 1);

    return depth + 1;
  }

  // eslint-disable-next-line max-len
  private determineBandDepthsAndLayerCount(bandVertexes: go.Map<string, go.Set<go.LayeredDigraphVertex>>): [go.Map<go.LayeredDigraphVertex, number>, number] {
    // for each band, determine how many layers are needed by looking for longest
    // chain of vertexes with that band name
    let totalLayerCount = 0;
    const allBandDepths = new go.Map<go.LayeredDigraphVertex, number>();  // map vertex to relative depth within band

    bandVertexes.each(bandVertex => {
      const name: string = bandVertex.key;
      const vertexes: go.Set<go.LayeredDigraphVertex> = bandVertex.value;
      const vertexDepthPairs: go.Map<go.LayeredDigraphVertex, number> = new go.Map();  // map vertex to relative depth within band NAME
      vertexes.each(vertex => this.walkAllWithinBand(name, vertex, vertexDepthPairs));

      let max = 0;
      vertexDepthPairs.each(vertexDepthPair => {
        max = Math.max(max, vertexDepthPair.value);
        allBandDepths.set(vertexDepthPair.key, vertexDepthPair.value);
      });

      const bandDescriptor = this.bandNames.get(name);
      if (bandDescriptor) {
        bandDescriptor.depth = max;
      }
      totalLayerCount += Math.max(max, 1);  // assume a band requires a layer, even if there are no nodes in it
    });

    return [allBandDepths, totalLayerCount];
  }

  private assignLayerToVertexes(allBandDepths: go.Map<go.LayeredDigraphVertex, number>, totalLayerCount: number) {
    // assign vertex.layer for each vertex
    const vertexIterator = this.network.vertexes.iterator;
    while (vertexIterator.next()) {
      const vertex = vertexIterator.value as go.LayeredDigraphVertex;
      if (vertex.node === null || vertex.node.data === null) {
        continue;
      }

      const name = vertex.node.data.bandName;
      const bandDescriptor = this.bandNames.get(name);
      const depth = allBandDepths.get(vertex);
      if (bandDescriptor && typeof depth === 'number') {
        // reverse the layer number -- last layer is number zero
        vertex.layer = totalLayerCount - (bandDescriptor.start + depth);
      }
    }
  }

  protected assignLayers() {
    // map band name to band descriptor holding name and layer ranges
    this.bandNames = new go.Map<string, any>();

    const bands = this.diagram.findPartForKey('_BANDS');
    if (!bands) {
      return;
    }

    const bandDescriptors: Array<BandDescriptor> = bands.data.descriptors;
    if (!Array.isArray(bandDescriptors)) {
      return;
    }

    for (const bandDescriptor of bandDescriptors) {
      this.bandNames.set(bandDescriptor.text, bandDescriptor);
    }

    // collect all vertexes for each band
    const bandVertexes = this.collectVertexesForBands();
    // determine depth for each band and total number of needed layers
    const [allBandDepths, totalLayerCount] = this.determineBandDepthsAndLayerCount(bandVertexes);

    // iterate over the bands and assign layer number ranges
    let layer = 0;
    for (const bandDescriptor of bandDescriptors) {
      bandDescriptor.start = layer;
      layer += bandDescriptor.depth;
    }

    this.assignLayerToVertexes(allBandDepths, totalLayerCount);
  }

  protected commitLayers(layerRects: Array<go.Rect>, offset: go.Point) {
    // update the background object holding the visual "bands"
    const bands = this.diagram.findPartForKey('_BANDS');
    if (bands) {
      const model = this.diagram.model;
      bands.location = this.arrangementOrigin.copy().add(offset);

      // set the bounds of each band via data binding of the "bounds" property
      const bandDescriptors = bands.data.descriptors;
      let j = 0;
      for (let i = 0; i < bandDescriptors.length && j < layerRects.length; i++) {
        const bandDescriptor = bandDescriptors[i];
        if (!bandDescriptor){
          continue;
        }

        let k = j;
        let thickness = 0;
        for (; k < Math.min(j+bandDescriptor.depth, layerRects.length); k++) {
          if (this.direction === 90 || this.direction === 270) {
            thickness += layerRects[k].height;
          } else {
            thickness += layerRects[k].width;
          }
        }
        const r = layerRects[j].copy();  // first rect gives shared bounds info
        if (this.direction === 90 || this.direction === 270) {
            if (this.direction === 270) {
              r.y = layerRects[k-1].y;
            }
            r.height = thickness;
          } else {
            if (this.direction === 180) {
              r.x = layerRects[k-1].x;
            }
            r.width = thickness;
        }
          model.set(bandDescriptor, 'bounds', r); // an actual Rect, not stringified
          j = k;  // next layer/band
      }
    }
    this.bandNames = null; // cleanup
  }
}

This is the node template:

  $(go.Node, 'Spot',
    {
      fromSpot: horizontal ? go.Spot.Right : go.Spot.Bottom,
      toSpot: horizontal ? go.Spot.Left : go.Spot.Top,
      fromEndSegmentLength: 16,
      toEndSegmentLength: 16,
      movable: false,
      resizable: false,
      selectionAdorned: false,
    },

    $(go.Panel, 'Auto',
      {
        desiredSize: new go.Size(184, 68),
      },

      $(go.Shape, 'RoundedRectangle',
        {
          parameter1: 4, // Specifies the corner radius for RoundedRectangle figure.
          stretch: go.GraphObject.Fill,
          fill: 'white',
          stroke: 'black',
          strokeWidth: 2,
        },
      ),


      $(go.TextBlock,
        {
          font: 'bold 64px Noto Sans',
        },
        new go.Binding('text', 'version'),
      ),
    )
  );

Please let me know if you want me to provide the node and link data.
The example I prepared is rather large (54KiB node data and 44KiB of link data) and I do not want to insert a block comment containing information of this size without asking first.

Please let me know if you need more information.

Thanks in advance for your help.

For performance reasons when there are a lot of nodes or links, it disables the LayeredDigraphLayout | GoJS API. Explicitly set that property to the combination of flags that you want it to use.

I hope it won’t become too slow for your cases.

Hi Walter.

Thanks for your quick reply.

I set the property as follows:

packOption: go.LayeredDigraphLayout.PackMedian | go.LayeredDigraphLayout.PackExpand

This way, the diagram looks - for the most part - as expected (see a screenshot of the example diagram below).

However, as you pointed out, the performance becomes an issue.
Is there a way - from my side - to increase the performance for the packing algorithm?
If not, will there be performance optimizations in future releases (I am currently using 2.2.17)?

Alternatively, do you have any other suggestions for what I could do?

Not in the near future. Sorry. Although there is one possibility that we haven’t tried yet.

For your largest case, how much faster does it need to be?

Is there any chance you would be able to accept a situation where the layout were performed in a Worker?

I cannot give you an exact number, what our largest use-cases are, but the biggest data i have seen was around 20.000 nodes.

For one sample dataset, with around 900 nodes, loading all nodes into the graph took around 18 minutes with the changed property. Using the default setting it took about one minute.

Commonly, the diagram is initialized with 100 nodes. The users have the option to extend the diagram on specific nodes, which also adds up to 100 nodes, or load all available data.
With the former case, the calculation took about twice as long.

How would that change the interaction with the diagram?
At the moment, the diagram is updated in a separate thread, without affecting the client software, in which we use the diagram. While the diagram is updated, a loading animation is shown.

There’s no way that it can produce a good layout for 20000 nodes in a reasonable time.

TreeLayout would be fast enough, but it would not produce a good layout for nodes that have multiple incoming links. Still, your example case is basically tree-like, so it might be good enough. Try it.

I thought as much. I just wanted to give you an estimate of the upper limit of the data I know of.

Yes, TreeLayout is faster.
However, this does not meet the core requirement that the layout needs to fulfill, namely the categorization of nodes into bands. In my case, the calendar week in which a data object was created.

I think that could be achieved with the BandedTreeLayout that is in Swim Bands using a Background Part, although some modifications will be required.

Thanks for pointing that out. I will look into it.

Right now I am unsure why I did not use the BandedTreeLayout in the past and instead use the BandedLayeredDigraphLayout, but maybe this is the solution.

Apparently that particular sample only supports angle: 0, not angle: 90 as you want.

In addition, it does not support having bands that have multiple layers in them.

Here’s a variation where the TreeLayout.angle is 90. Sorry, I haven’t made the other adaptation that you need.

<!DOCTYPE html>
<html>
<head>
  <title>Swim Bands using a Background Part</title>
  <!-- Copyright 1998-2022 by Northwoods Software Corporation. -->
  <meta name="description" content="implementing background bands using a single background Part">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px blue; width:100%; height:600px;"></div>
  <button onclick="addLayer()">Add Layer 5</button>

  <script src="../release/go.js"></script>
  <script id="code">

// Perform a TreeLayout where the node's actual tree-layer is specified by the "band" property on the node data.
// This implementation only works when angle == 0 or 90, but could be easily modified to support other angles.
class BandedTreeLayout extends go.TreeLayout {
  constructor() {
    super();
    this.treeStyle = go.TreeLayout.StyleLayered;  // required
    this.layerStyle = go.TreeLayout.LayerUniform;
    this.arrangement = go.TreeLayout.ArrangementHorizontal;
    // don't move subtrees closer together, to maintain possible empty spaces between layers
    this.compaction = go.TreeLayout.CompactionNone;
    // move the parent node towards the top of its subtree area
    this.alignment = go.TreeLayout.AlignmentStart;

    this.sorting = go.TreeLayout.SortingAscending;
    // sort a parent's child vertexes by the value of the index property
    this.comparer = (v, w) => {
        var vidx = v.index;
        if (vidx === undefined) vidx = 0;
        var widx = w.index;
        if (widx === undefined) widx = 0;
        return vidx-widx;
      };

    //this.setsPortSpot = false;
    //this.setsChildPortSpot = false;
  }

  // Modify the standard LayoutNetwork by making children with the same "band" value as their
  // parents actually be children of the grandparent.
  makeNetwork(coll) {
    var net = super.makeNetwork(coll);
    // add artificial root and link with all singleton vertexes that have node.data.band > 0
    var singles = [];
    for (var it = net.vertexes.iterator; it.next();) {
      var v = it.value;
      if (v.node && v.node.data.band > 0 &&
          v.destinationEdges.count === 0 &&
          v.sourceEdges.count === 0) {
        singles.push(v);
      }
    }
    if (singles.length > 0) {
      var dummyroot = net.createVertex();
      net.addVertex(dummyroot);
      singles.forEach(v => net.linkVertexes(dummyroot, v, null));
    }
    // annotate every child with an index, used for sorting
    for (var it = net.vertexes.iterator; it.next();) {
      var parent = it.value;
      var idx = 0;
      for (var cit = parent.destinationVertexes; cit.next();) {
        var child = cit.value;
        child.index = idx;
        idx += 10000;
      }
    }
    // now look for children with the same band value as their parent
    for (var it = net.vertexes.iterator; it.next();) {
      var parent = it.value;
      if (!parent.node) continue;
      // Should this be recursively looking for grandchildren/greatgrandchildren that
      // have the same band as this parent node??  Assume that is NOT required.
      var parentband = parent.node.data.band;
      var edges = [];
      for (var eit = parent.destinationEdges; eit.next();) {
        var edge = eit.value;
        var child = edge.toVertex;
        if (!child.node) continue;
        var childband = child.node.data.band;
        if (childband <= parentband) edges.push(edge);
      }
      // for each LayoutEdge that connects the parent vertex with a child vertex
      // whose node has the same band #, reconnect the edge with the parent's parent vertex
      var grandparent = parent.sourceVertexes.first();
      if (grandparent !== null) {
        var cidx = 1;
        for (var i = 0; i < edges.length; i++) {
          var e = edges[i];
          parent.deleteDestinationEdge(e);
          e.fromVertex = grandparent;
          grandparent.addDestinationEdge(e);
          var child = e.toVertex;
          child.index = parent.index + cidx;
          cidx++;
        }
      }
    }
    return net;
  };

  assignTreeVertexValues(v) {
    if (v.node && v.node.data && v.node.data.band) {
      v.originalLevel = v.level;  // remember tree assignment
      v.level = Math.max(v.level, v.node.data.band);  // shift down to meet band requirement
    }
  };

  commitLayers(layerRects, offset) {
    // for debugging:
    //for (var i = 0; i < layerRects.length; i++) {
    //  if (window.console) window.console.log(layerRects[i].toString());
    //}

    for (var it = this.network.vertexes.iterator; it.next(); ) {
      var v = it.value;
      var n = v.node;
      if (n && v.originalLevel) {
        // the band specifies the horizontal position
        var diff = n.data.band - v.originalLevel;
        if (diff > 0) {
          var pos = v.bounds.position;
          if (this.angle === 90) {
            pos.y = layerRects[v.level].y;
          } else {
          // this assumes that the angle is zero: rightward growth
            pos.x = layerRects[v.level].x;
          }
          n.move(pos);
        }
      }
    }

    // update the background object holding the visual "bands"
    var bands = this.diagram.findPartForKey("_BANDS");
    if (bands) {
      bands.layerRects = layerRects;  // remember the Array of Rect

      var model = this.diagram.model;
      for (var it = this.network.vertexes.iterator; it.next(); ) {
        var v = it.value;
        if (!v.node) continue;
        model.setDataProperty(v.node.data, "band", v.level);
      }

      bands.location = this.arrangementOrigin.copy().add(offset);

      var arr = bands.data.itemArray;
      for (var i = 0; i < layerRects.length; i++) {
        var itemdata = arr[i];
        if (itemdata) {
          model.setDataProperty(itemdata, "bounds", layerRects[i]);
        }
      }
    }
  }
}
// end of BandedTreeLayout


const $ = go.GraphObject.make;
myDiagram =
  new go.Diagram("myDiagramDiv",
    {
      layout: $(BandedTreeLayout, { angle: 90 }),  // custom layout is defined below
      "undoManager.isEnabled": true
    });

myDiagram.nodeTemplate =
  $(go.Node, go.Panel.Auto,
    $(go.Shape, "Rectangle",
      { fill: "white" }),
    $(go.TextBlock, { margin: 5 },
      new go.Binding("text", "key")));

// there should be a single object of this category;
// it will be modified by BandedTreeLayout to display visual "bands"
myDiagram.nodeTemplateMap.add("VerticalBands",
  $(go.Part, "Position",
    {
      isLayoutPositioned: false,  // but still in document bounds
      //locationSpot: new go.Spot(0, 0, 0, 16),  // account for header height
      locationSpot: new go.Spot(0, 0, 100, 0),  // account for header width
      layerName: "Background",
      pickable: false,
      selectable: false,
      itemTemplate:
        $(go.Panel, "Horizontal", //"Vertical",
          new go.Binding("opacity", "visible", v => v ? 1 : 0),
          new go.Binding("position", "bounds", b => b.position),
          $(go.TextBlock,
            {
              width: 100,
              stretch: go.GraphObject.Fill,
              //textAlign: "center",
              textAlign: "right",
              verticalAlignment: go.Spot.Center,
              //wrap: go.TextBlock.None,
              font: "bold 11pt sans-serif",
              //background: $(go.Brush, go.Brush.Linear, { 0: "lightgray", 1: "whitesmoke" })
            },
            new go.Binding("text"),
            //new go.Binding("width", "bounds", r => r.width)
            new go.Binding("height", "bounds", r => r.height)
            ),
          // for separator lines:
          //$(go.Shape, "LineV",
          //  { stroke: "gray", alignment: go.Spot.Left, width: 1 },
          //  new go.Binding("height", "bounds", r => r.height),
          //  new go.Binding("visible", "itemIndex", i => i > 0).ofObject()),
          // for rectangular bands:
          $(go.Shape,
            { stroke: null, strokeWidth: 0 },
            new go.Binding("desiredSize", "bounds", r => r.size),
            new go.Binding("fill", "itemIndex", i => i % 2 == 0 ? "white" : "whitesmoke").ofObject())
        )
    },
    new go.Binding("itemArray")
  ));

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

// define the tree node data
var nodearray = [
  {
    key: "_BANDS",
    category: "VerticalBands",
    itemArray: [
      { text: "Zero" /*, visible: false*/ },
      { text: "One" },
      { text: "Two" },
      { text: "Three" },
      { text: "Four" }
    ]
  },
  { key: "root", band: 0 },
  { key: "oneB", band: 3, parent: "root" },
  { key: "twoA", band: 4, parent: "oneB" },
  { key: "twoC", band: 2, parent: "root" },
  { key: "threeC", band: 4, parent: "twoC" },
  { key: "threeD", band: 2, parent: "twoC" },
  { key: "fourB", band: 2, parent: "threeD" },
  { key: "fourC", band: 3, parent: "twoC" },
  { key: "fourD", band: 4, parent: "fourB" },
  { key: "twoD", band: 1, parent: "root" },
  { key: "extra0", band: 0 },
  { key: "extra1", band: 1 },
  { key: "extra3", band: 3 }
];
myDiagram.model = new go.TreeModel(nodearray);

function addLayer() {
  if (myDiagram.findPartForKey("fiveA") !== null) return;
  myDiagram.startTransaction("addLayer");
  var bands = myDiagram.model.findNodeDataForKey("_BANDS");
  if (bands) myDiagram.model.addArrayItem(bands.itemArray, { text: "Five" });
  myDiagram.model.addNodeData({ key: "fiveA", parent: "fourB", band: 5 });
  myDiagram.commitTransaction("addLayer");
}
  </script>
</body>
</html>

Thanks again.

I think I should be able to add the ability of multiple layers within a band by adapting the Layout I currently use to the TreeLayout. I will let you know of my progress.