SubGraphExpander button - links not re-routed

Yes, I have been running and modifying your code quite a bit.

I find that the best results come when I make the following changes:

  • comment out the setting of Link.adjusting
  • changed your TwoWay Binding of Link.points
  • remove the call to setTimeout and just replace the Diagram.model with a new GraphLinksModel using your node data and your link data

I didn’t like how you implemented your custom Adornment for your nodes, so I initially removed that code. But that doesn’t really affect any link routing, so putting your code back in did not affect the initial routing nor the routing when users collapse or expand lanes/groups.

      const linkTemplate = new go.Link({
          reshapable: true,
          resegmentable: true,
          routing: go.Routing.Orthogonal,
          //adjusting: go.LinkAdjusting.End
        })
        .bindTwoWay("points")
        // .bindTwoWay(
        //   "points",
        //   "points",
        //   (points) => points.map(go.Point.parse),
        //   (points) => {
        //     const result = points.map(go.Point.stringify).toArray();
        //     console.trace(result);
        //     return result;
        //   }
        // )
        .add(new go.Shape({ strokeWidth: 1.5, stroke: "red" }))
        .add(
          new go.Shape({
            strokeWidth: 0,
            fill: "red",
            scale: 0.7,
            fromArrow: "circle",
          })
        )
        .add(
          new go.Shape({
            strokeWidth: 0,
            fill: "red",
            scale: 0.7,
            toArrow: "circle",
          })
        );
      diagram.model = new go.GraphLinksModel(nodes, links);

      // setTimeout(() => {
      //   diagram.model.addNodeDataCollection(nodes);
      //   diagram.model.addLinkDataCollection(links);
      // });

LinkAdjusting.End is used in the project to maintain the link’s shape. If you comment this out, change the shape of the link and collapse/expand it, your shape customization will be lost.

Wait – did you want to maintain any manual link route reshaping across lane collapse-expands? I don’t see how that can work, since either or both of the lanes connected by a link might become hidden due to their lanes collapsing.

And if you want to support links being rerouted to connect to the lane(s) directly instead of to their actual (hidden) nodes, would that imply you might need to store a link route for each possible combination of collapsed/expanded lanes? Not only the lanes containing the fromNode and toNode, but also for all lanes/groups between them.

I think it would make sense to store link points for each combination but for now the problem with LinkAdjusting is still there.

With LinkAdjusting.End my Alpha - Beta link routing is maintained (I want a part of the link to be outside of the pool):

With LinkAdjusting.None the routing resets after collapse/expand:

Try adding this “LayoutCompleted” DiagramEvent listener:

      const diagram = new go.Diagram("diagramDiv", {
        "animationManager.isEnabled": false,
        "LayoutCompleted": e => e.diagram.links.each(l => l.invalidateRoute())
      });

(In addition to setting Link.adjusting to LinkAdjusting.End.)

Here I have replaced your implementation of each Node’s “Adornment” using a subclass of Node. Note how there is no odd Binding on the node template.

<!DOCTYPE html>
<html lang="en">
  <body>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/release/go-debug.js"></script>
    <div id="sample" style="display: flex">
      <div
        id="diagramDiv"
        style="border: solid 1px black; width: 600px; height: 600px"
      ></div>
    </div>

    <script id="code">
class AdornedNode extends go.Node {
  static AdName = "Adornment";
  static AdTemplate =
    new go.Adornment("Spot")
      .add(
        new go.Panel("Vertical")
          .add(
            new go.Placeholder(),
            new go.TextBlock({ text: "Adornment" })
          )
      ).copyTemplate(true);

  constructor(paneltype, init) {
    if (typeof paneltype === "object") { init = paneltype; paneltype = undefined; }
    super(paneltype);
    if (init) Object.assign(this, init);
  }

  // this design assumes the "Adornment" should only be shown when Node.isHighlighted is true
  maybeShowAdornment() {
    let ad = this.findAdornment(AdornedNode.AdName);
    if (this.isHighlighted && this.isVisible()) {
      if (ad === null) {
        ad = AdornedNode.AdTemplate.copy();
        ad.adornedObject = this;
        this.addAdornment(AdornedNode.AdName, ad);
      }
    } else {
      if (ad !== null) {
        this.removeAdornment(AdornedNode.AdName);
      }
    }
  }
}

const diagram = new go.Diagram("diagramDiv", {
  "animationManager.isEnabled": false,
  "LayoutCompleted": e => {
    e.diagram.links.each(l => l.invalidateRoute());
    e.diagram.nodes.each(n => {
      if (n instanceof AdornedNode) n.maybeShowAdornment();
    })
  },
  "undoManager.isEnabled": true
});

const nodeTemplate = new AdornedNode /*go.Node*/("Auto", {
    // when Node.isHighlighted changes value, call AdornedNode.maybeShowAdornment
    highlightedChanged: node => node.maybeShowAdornment(),
    // user can control which nodes have "Adornment" by double-clicking
    doubleClick: (e, node) => {
      e.diagram.commit(d => node.isHighlighted = !node.isHighlighted)
    }
  })
  .bindTwoWay("isHighlighted", "isAdorned")
  .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
  .add(
    new go.Shape("RoundedRectangle", {
        strokeWidth: 0,
        fill: "white",
        fromSpot: go.Spot.AllSides,
        toSpot: go.Spot.AllSides,
        portId: '',
      })
      .bind("fill", "color")
      .bindTwoWay("fromSpot", "fromSpot", go.Spot.parse, go.Spot.stringify)
      .bindTwoWay("toSpot", "toSpot", go.Spot.parse, go.Spot.stringify),
    new go.TextBlock({
        margin: 8,
        font: "bold 14px sans-serif",
        stroke: "#333",
      })
      .bind("text")
  );

const poolTemplate = new go.Group("Auto", {
    layout: new go.GridLayout({
      alignment: go.GridAlignment.Position,
      comparer: (a, b) => a.position.x - b.position.x,
      spacing: new go.Size(0, 0),
      cellSize: new go.Size(0, 0),
      wrappingColumn: Infinity,
      wrappingWidth: Infinity,
      isRealtime: true,
    })
  })
  .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
  .add(
    new go.Shape("RoundedRectangle", {
      strokeWidth: 1,
      fill: "white",
      width: 400,
      height: 400,
    })
  );

const laneTemplate = new go.Group("Vertical", {
    isSubGraphExpanded: true,
    fromSpot: go.Spot.LeftRightSides, toSpot: go.Spot.LeftRightSides
  })
  .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
  .bindTwoWay("isSubGraphExpanded")
  .add(
    go.GraphObject.make("SubGraphExpanderButton", {
      click: (event, thisObj) => {
        thisObj.part.diagram.startTransaction("Toggle");
        if (thisObj.part.isSubGraphExpanded) {
          thisObj.part.collapseSubGraph();
        } else {
          thisObj.part.expandSubGraph();
        }
        thisObj.part.diagram.commitTransaction("Toggle");
      },
    }),
    new go.Shape("RoundedRectangle", {
        strokeWidth: 1,
        fill: "white",
        width: 150,
        height: 350,
      }).bindObject("desiredSize", "isSubGraphExpanded",
        (isSubGraphExpanded) =>
          isSubGraphExpanded ? new go.Size(150, 350) : new go.Size(30, 350))
  );

const linkTemplate = new go.Link({
    reshapable: true,
    resegmentable: true,
    routing: go.Routing.Orthogonal,
    adjusting: go.LinkAdjusting.End
  })
  .bindTwoWay("points")
  .add(new go.Shape({ strokeWidth: 1.5, stroke: "red" }))
  .add(
    new go.Shape({
      strokeWidth: 0,
      fill: "red",
      scale: 0.7,
      fromArrow: "circle",
    })
  )
  .add(
    new go.Shape({
      strokeWidth: 0,
      fill: "red",
      scale: 0.7,
      toArrow: "circle",
    })
  );

diagram.nodeTemplate = nodeTemplate;
diagram.groupTemplateMap.add("Pool", poolTemplate);
diagram.groupTemplateMap.add("Lane", laneTemplate);
diagram.linkTemplate = linkTemplate;

const nodes = [
  {
    key: "P",
    isGroup: true,
    category: "Pool",
    location: "31.24609375 -0.3515625",
  },
  {
    key: "L1",
    group: "P",
    isGroup: true,
    category: "Lane",
    location: "31.24609375 -0.3515625",
    isSubGraphExpanded: true,
  },
  {
    key: "L2",
    group: "P",
    isGroup: true,
    category: "Lane",
    location: "62.24609375 -0.3515625",
    isSubGraphExpanded: true,
  },
  {
    key: "L3",
    group: "P",
    isGroup: true,
    category: "Lane",
    location: "62.24609375 -0.3515625",
    isSubGraphExpanded: true,
  },
  {
    key: "N1",
    group: "L1",
    text: "Alpha",
    color: "lightblue",
    location: "74.30859375 157.82421875",
  },
  {
    key: "N2",
    group: "L2",
    text: "Beta",
    color: "orange",
    location: "150.8359375 88.375",
  },
  {
    key: "N3",
    group: "L3",
    text: "Gamma",
    color: "lightgreen",
    location: "100.37538656080795 203.03515625",
    isAdorned: true
  },
];

const links = [
  {
    from: "N1",
    to: "N3",
  },
  {
    from: "N1",
    to: "N2",
  },
];

diagram.model = new go.GraphLinksModel(nodes, links);
    </script>
  </body>
</html>

It looks like it helps. But why didn’t the same thing work when I was calling the invalidateRoute in the collapse/expand transaction?

Also is it ok to call the invalidateRoute on each LayoutCompleted on more than five hundred links?

Thanks for the adornment improvement suggestions. I’ll give it a try when there will be dedicated time for adornment related issues.

I suppose you should reduce the number of calls to Link.invalidateRoute by checking that the Link.fromNode’s containingGroup is not the same as the Link.toNode’s containingGroup.

Why didn’t the invalidateRoute in the collapse/expand transaction work but it works as a LayoutCompleted callback?

The code:

group.expandSubGraph();
findLinks(...).forEach(l => l.invalidateRoute());

might or might not do what you want because the call to Group.expandSubGraph sets the Group.isSubGraphExpanded property but doesn’t synchronously layout the now visible members (which might themselves be Groups that are expanded), nor does it synchronously layout the nodes (including that Group. i.e. in your case the lanes’ container), etc up the hierarchy of Groups. So when something causes the invalidated Link routes to be recomputed, the Nodes might not yet be where they will ultimately be positioned.

In your case I suspect that once the lane contents are laid out (which might be a no-op because you haven’t set Group.layout to anything), the Nodes are not where they will be at the end because the pool’s Group.layout hasn’t yet run to arrange the lanes next to each other. Normally that’s OK, because any computed route for those cross-lane links would just be discarded and recomputed when the lanes have finished being positioned, but when Link.adjusting is not None, their intermediate points are kept rather than be discarded. Hmmm, this explanation must be incomplete.

Anyway, by making sure the invalidation happens late enough (i.e. after all of the lanes and the pools have been positioned), then the link routes will be computed reasonably. Although due to Link.adjusting I think you’ll still sometimes see some non-optimal routes. In fact, I think you can see that in the lower link – there’s an extra turn in the route.

Maybe a better strategy is to not set Link.adjusting in the template but to customize the DraggingTool so that it temporarily sets Link.adjusting to End on all links connected with nodes that are being moved.

Here’s another possibility: you only set Link.adjusting to End when the user has reshaped the link route, because only then can one say that it’s a route whose path we want to preserve.

You can accomplish this by adding a “LinkReshaped” DiagramEvent listener:

const diagram = new go.Diagram("diagramDiv", {
  . . .,
  "LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End,
  . . .
})

Then add a TwoWay Binding on the Link:

const linkTemplate = new go.Link({
    reshapable: true,
    resegmentable: true,
    routing: go.Routing.Orthogonal,
    //adjusting: go.LinkAdjusting.End // only set when route is reshaped by LinkReshapingTool
  })
  .bindTwoWay("adjusting")