Add empty band using TreeLayout

I’m trying to render empty bands in a TreeLayout (actually a custom layout called BandedTreeLayout which inherits from TreeLayout). I need to be able to show all bands all the time, whether or not there are nodes in the band layer.

I found this topic (https://forum.nwoods.com/t/hoe-to-add-an-empty-band-to-the-layereddigraph/8203) where @walter said he had a solution for TreeLayout, but didn’t post it. The posted solution for LayeredDigraphLayout doesn’t work for me (it does what the image attached to that post shows - but it doesn’t actually render empty bands, no matter what I change the nodeArray values to).

GoJS version: 1.7.26
Platform: Windows 10 (development machine).
Browsers: Chrome 62.0.3202.94 (64 bit), Firefox 57.0 (64-bit)

Here’s one such extension of TreeLayout that assumes the bands are implemented using a single background Part holding the visual “bands”:

  // 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, but could be easily modified to support other angles.
  function LayeredTreeLayout() {
    go.TreeLayout.call(this);

    this.treeStyle = go.TreeLayout.StyleLayered;  // required
    // new in GoJS v1.4
    this.layerStyle = go.TreeLayout.LayerUniform;

    // 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;

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

    //this.setsPortSpot = false;
    this.setsChildPortSpot = false;
  }
  go.Diagram.inherit(LayeredTreeLayout, go.TreeLayout);

  // Modify the standard LayoutNetwork by making children with the same "band" value as their
  // parents actually be children of the grandparent.
  LayeredTreeLayout.prototype.makeNetwork = function(coll) {
    var net = go.TreeLayout.prototype.makeNetwork.call(this, coll);
    // 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;
      // 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;
        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;
  };

  LayeredTreeLayout.prototype.assignTreeVertexValues = function(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
    }
  };

  LayeredTreeLayout.prototype.commitLayers = function(layerRects, offset) {
    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;
          // 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;
        model.setDataProperty(v.node.data, "level", 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]);
        }
      }
    }
  };

For example, this is the background “bands” part:

    // there should be a single object of this category;
    // it will be modified by LayeredTreeLayout 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
          layerName: "Grid",  // not pickable, not selectable
          itemTemplate:
            $(go.Panel, "Vertical",
              new go.Binding("opacity", "visible", function(v) { return v ? 1 : 0; }),
              new go.Binding("position", "bounds", function(b) { return b.position; }),
              $(go.TextBlock,
                {
                  stretch: go.GraphObject.Horizontal,
                  textAlign: "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", function(r) { return r.width; })),
              // for separator lines:
              //$(go.Shape, "LineV",
              //  { stroke: "gray", alignment: go.Spot.Left, width: 1 },
              //  new go.Binding("height", "bounds", function(r) { return r.height; }),
              //  new go.Binding("visible", "itemIndex", function(i) { return i > 0; }).ofObject()),
              // for rectangular bands:
              $(go.Shape,
                { stroke: null, strokeWidth: 0 },
                new go.Binding("desiredSize", "bounds", function(r) { return r.size; }),
                new go.Binding("fill", "itemIndex", function(i) { return i % 2 == 0 ? "white" : "lightgray"; }).ofObject())
            )
        },
        new go.Binding("itemArray")
      ));

which could be set up in the model via:

    // define the tree node data
    var nodearray = [
      {
        key: "_BANDS",
        category: "VerticalBands",
        itemArray: [
          { text: "Zero" },
          { text: "One" /*, visible: false*/ },
          { 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" }
    ];

    myDiagram.model = new go.TreeModel(nodearray);

Thanks @walter. I’m not sure if I’m doing something wrong, but if I remove the “twoD” node:

var nodearray = [ { key: "_BANDS", category: "VerticalBands", itemArray: [ { text: "Zero" }, { text: "One" /*, visible: false*/ }, { 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" }*/ ];

Then the “One” band disappears:

Is that the expected behaviour?

I’ve got a hacky workaround for this problem. I added a node for each band, each one linked to the node in the next band (i.e. in this example it would be 5 nodes in a line). I then give the links opacity: 0, and it almost looks right. Not a pretty solution, though!

Yes, I think that’s what some people expect, but the alternative is perfectly reasonable too.

I would think changing the layout to pretend that there are vertexes in each layer would be a better way to accomplish what you accomplished by using transparent nodes and links.

OK, thanks for your reply. I’ll stick with the transparent links hacky solution, then.