Animation during node reordering

With SwimLaneLayout, I noticed that the node order in nodeDataArray decides the node order in the same layer. For example, the following diagram has the nodeDataArray and linkDataArray as

const nodeDataArray = [
  {
    key: "A",
    isGroup: true,
  },
  {
    key: "Box1",
    group: "A",
  },
  {
    key: "Box2",
    group: "A",
  },
  {
    key: "Box3",
    group: "A",
  },
  {
    key: "Box4",
    group: "A",
  },
  {
    key: "Box5",
    group: "A",
  },
];

const linkDataArray = [
  {
    from: "Box1",
    to: "Box2",
  },
  {
    from: "Box1",
    to: "Box3",
  },
  {
    from: "Box1",
    to: "Box4",
  },
  {
    from: "Box2",
    to: "Box5",
  },
];

before-reorder

If I reorder the nodeDataArray by moving Box2 below Box4 as

[
  {
    key: "Box1",
    group: "A",
  },
  {
    key: "Box3",
    group: "A",
  },
  {
    key: "Box4",
    group: "A",
  },
  {
    key: "Box2",
    group: "A",
  },
  {
    key: "Box5",
    group: "A",
  },
];

The figure changes to
after-reorder

I want to animate the reordering by clicking a button. If I assigned a new array to diagram.model.nodeDataArray between diagram.startTransation and diagram.commitTransaction, the animation involved all the nodes as shown below because I updated nodeDataArray as a whole.

global-recorder

myDiagram.startTransaction("reorder node");
myDiagram.model.nodeDataArray = [
  {
    key: "Box1",
    group: "A",
  },
  {
    key: "Box3",
    group: "A",
  },
  {
    key: "Box4",
    group: "A",
  },
  {
    key: "Box2",
    group: "A",
  },
  {
    key: "Box5",
    group: "A",
  },
];
myDiagram.commitTransaction("reorder node");

If I removed box2 and added it back between diagram.startTransation and diagram.commitTransaction, the animation is less aggressive as shown below, which I think looks better.

incremental-reorder

myDiagram.startTransaction("reorder node 2");
let removed;
const nodeId = myDiagram.nodes;
while (nodeId.next()) {
  const node = nodeId.value;
  if (node.data.key === "Box2") {
    removed = node.data;
    break;
  }
}
if (removed) {
  myDiagram.model.removeNodeData(removed);
  myDiagram.model.addNodeData({
    key: "Box2",
    group: "A",
  });
}
myDiagram.commitTransaction("reorder node 2");

So, my question is if removing and adding the same node is the correct approach to implement the node reordering in SwimLaneLayout? Is there any better approach?

I am looking for a better approach because removing and adding the same node does NOT work if I want to move Box2 between Box3 and Box4 since Box2 is always added to the end of the nodeDataArray. Besides addNodeData and removeNodeData, is there any model method to insert node to a specific index?
reorder-to-middle

I am adding the complete HTML + JS code below so that you can play with.

  • Clicking the Reorder Node button causes the aggressive animation as shown in the first animated gif,
  • Clicking the Incremental Reorder Node button causes the less aggressive animation as shown in the second animated gif,
  • Clicking the Reorder Box2 to middle button moves Box2 between Box3 and Box4 but I could only accomplish it by reassigning diagram.model.nodeDataArray. Does GoJS have any approach to only animated the affect nodes and links?

Thanks so much for your time!

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div
      id="myDiagramDiv"
      style="border: solid 1px black; width: 100%; height: 700px"
    ></div>
    <div class="toolbar">
      <button id="reorder">Reorder Node</button>
      <button id="reorder2">Incremental Reorder Node</button>
      <button id="reorder3">Reorder Box2 to middle</button>
    </div>
    <script src="../../release/go-debug.js"></script>
    <script src="../../extensions/SwimLaneLayout.js"></script>
    <script>
      let myDiagram;

      function init() {
        const $ = go.GraphObject.make;
        myDiagram = $(go.Diagram, "myDiagramDiv", {
          layout: $(SwimLaneLayout, {
            laneProperty: "group",
            layerSpacing: 20,
            commitLayers: function (layerRects, offset) {
              if (layerRects.length === 0) return;

              var horiz = true;
              var forwards = true;

              var rect = layerRects[forwards ? layerRects.length - 1 : 0];
              var totallength = horiz ? rect.right : rect.bottom;

              for (var i = 0; i < this.laneNames.length; i++) {
                var lane = this.laneNames[i];
                // assume lane names do not conflict with node names
                var group = this.diagram.findNodeForKey(lane);
                if (group === null) {
                  this.diagram.model.addNodeData({ key: lane, isGroup: true });
                  group = this.diagram.findNodeForKey(lane);
                }
                if (horiz) {
                  group.location = new go.Point(
                    -this.layerSpacing / 2,
                    this.lanePositions.get(lane) * this.columnSpacing +
                      offset.y,
                  );
                } else {
                  group.location = new go.Point(
                    this.lanePositions.get(lane) * this.columnSpacing +
                      offset.x,
                    -this.layerSpacing / 2,
                  );
                }
                var ph = group.findObject("PLACEHOLDER"); // won't be a go.Placeholder, but just a regular Shape
                if (ph === null) ph = group;
                if (horiz) {
                  ph.desiredSize = new go.Size(
                    totallength,
                    this.laneBreadths.get(lane) * this.columnSpacing,
                  );
                } else {
                  ph.desiredSize = new go.Size(
                    this.laneBreadths.get(lane) * this.columnSpacing,
                    totallength,
                  );
                }
              }
            },
          }),
        });

        myDiagram.nodeTemplate = $(
          go.Node,
          "Spot",
          new go.Binding("location", "loc", go.Point.parse),
          $(go.Shape, "RoundedRectangle", {
            fill: "white",
            width: 100,
            height: 50,
            portId: "",
            fromLinkable: true,
            toLinkable: true,
            cursor: "pointer",
          }),
          $(
            go.TextBlock, // the text label
            new go.Binding("text", "key"),
            {
              verticalAlignment: go.Spot.Center,
              textAlign: "center",
            },
          ),
        );

        myDiagram.linkTemplate = $(
          go.Link,
          {
            curve: go.Link.Bezier,
            toShortLength: 8,
            fromEndSegmentLength: 50,
            toEndSegmentLength: 50,
          },
          $(go.Shape, { isPanelMain: true, strokeWidth: 2 }),
          $(go.Shape, { toArrow: "Standard", stroke: null }),
        );

        myDiagram.groupTemplate = $(
          go.Group,
          "Horizontal",
          {
            layerName: "Background",
            movable: false,
            copyable: false,
            locationObjectName: "PLACEHOLDER",
            layout: null,
            avoidable: false,
          },
          $(
            go.TextBlock,
            {
              font: "bold 12pt sans-serif",
              angle: 270,
            },
            new go.Binding("text", "key"),
          ),
          $(
            go.Panel,
            "Auto",
            $(go.Shape, { fill: "transparent", stroke: "orange" }),
            $(go.Shape, {
              name: "PLACEHOLDER",
              fill: null,
              stroke: null,
              strokeWidth: 0,
            }),
          ),
        );

        const nodeDataArray = [
          {
            key: "A",
            isGroup: true,
          },
          {
            key: "Box1",
            group: "A",
          },
          {
            key: "Box2",
            group: "A",
          },
          {
            key: "Box3",
            group: "A",
          },
          {
            key: "Box4",
            group: "A",
          },
          {
            key: "Box5",
            group: "A",
          },
        ];

        const linkDataArray = [
          {
            from: "Box1",
            to: "Box2",
          },
          {
            from: "Box1",
            to: "Box3",
          },
          {
            from: "Box1",
            to: "Box4",
          },
          {
            from: "Box2",
            to: "Box5",
          },
        ];

        const model = new go.GraphLinksModel();
        model.nodeDataArray = nodeDataArray;
        model.linkDataArray = linkDataArray;
        myDiagram.model = model;

        myDiagram.addDiagramListener("SelectionMoved", (event) => {
          const node = event.subject.first();
          console.log(node.data);
        });
      }

      window.addEventListener("DOMContentLoaded", init);

      document.getElementById("reorder").addEventListener("click", (e) => {
        myDiagram.startTransaction("reorder node");
        myDiagram.model.nodeDataArray = [
          {
            key: "Box1",
            group: "A",
          },
          {
            key: "Box3",
            group: "A",
          },
          {
            key: "Box4",
            group: "A",
          },
          {
            key: "Box2",
            group: "A",
          },
          {
            key: "Box5",
            group: "A",
          },
        ];
        myDiagram.commitTransaction("reorder node");
      });

      document.getElementById("reorder2").addEventListener("click", (e) => {
        myDiagram.startTransaction("reorder node 2");
        let removed;
        const nodeId = myDiagram.nodes;
        while (nodeId.next()) {
          const node = nodeId.value;
          if (node.data.key === "Box2") {
            removed = node.data;
            break;
          }
        }
        if (removed) {
          myDiagram.model.removeNodeData(removed);
          myDiagram.model.addNodeData({
            key: "Box2",
            group: "A",
          });
        }
        myDiagram.commitTransaction("reorder node 2");
      });

      document.getElementById("reorder3").addEventListener("click", (e) => {
        myDiagram.startTransaction("reorder node 3");
        myDiagram.model.nodeDataArray = [
          {
            key: "Box1",
            group: "A",
          },
          {
            key: "Box3",
            group: "A",
          },
          {
            key: "Box2",
            group: "A",
          },
          {
            key: "Box4",
            group: "A",
          },
          {
            key: "Box5",
            group: "A",
          },
        ];
        myDiagram.commitTransaction("reorder node 3");
      });
    </script>
  </body>
</html>

Yes, replacing all of the nodes will animate them all. But you could start them all off at their old nodes’ locations, instead of at (0,0).

I’ll look into the possibilities.

Hi Walter,

Thanks for your quick response. Using location is an interesting idea. But does it mean I could not use the automatic layout management for SwimLaneLayout?

Looking forward to the possible solutions from you!

Thanks a lot!
Min

I’m not sure. The normal thing that I tried isn’t doing what I expected. Still looking…

OK, here’s your code, simplified and extended to support sorting of vertexes within each layer.

I defined a subclass of SwimLaneLayout just to organize the code better. it’s used as the Diagram.layout. Note how I have moved the override of SwimLaneLayout.commitLayers to the subclass.

I also got rid of two buttons and their click functions. Although I should say that you have a bug in your “reorder” button case – the new Model.nodeDataArray doesn’t have a Group in it.

I completely reimplemented the third button’s click function. It now sets the data.text property on the nodes whose relative ordering you want to control. That data.text property is used when comparing two LayeredDigraphVertexes in the overridden commitLayers method.

Caution: that comparer function, named _comp in the layout subclass, compares all of the vertexes in each layer, across all lanes, not just the ones in each layer that are only within each lane.

I have not had time to test this code.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div
      id="myDiagramDiv"
      style="border: solid 1px black; width: 100%; height: 700px"
    ></div>
    <div class="toolbar">
      <button id="reorder3">Reorder Box2 to middle</button>
    </div>
    <script src="../../release/go-debug.js"></script>
    <script src="../../extensions/SwimLaneLayout.js"></script>
    <script>
class CustomSwimLaneLayout extends SwimLaneLayout {
  // internal vertex comparer, for sorting all vertexes in a layer
  _comp(v, w) {
    const n = v.node;
    const m = w.node;
    if (n && m) {
      const nt = n.data.text;
      const mt = m.data.text;
      if (nt && mt) {
        if (nt > mt) return 1;
        if (nt < mt) return -1;
      }
    }
    return 0;
  }

  // override of SwimLaneLayout.computeLanes, called by reduceCrossings
  computeLanes() {
    var layers = this._layers;
    for (var i = 0; i < layers.length-1; i++) {
      var arr = layers[i];
      arr.sort(this._comp);
      arr.forEach(function(v, j) { v.index = j; })
    }
    super.computeLanes();
  }

  // override of SwimLaneLayout.commitLayers, for setting up the bounds of the groups
  commitLayers(layerRects, offset) {
    if (layerRects.length === 0) return;

    var horiz = true;
    var forwards = true;

    var rect = layerRects[forwards ? layerRects.length - 1 : 0];
    var totallength = horiz ? rect.right : rect.bottom;

    for (var i = 0; i < this.laneNames.length; i++) {
      var lane = this.laneNames[i];
      // assume lane names do not conflict with node names
      var group = this.diagram.findNodeForKey(lane);
      if (group === null) {
        this.diagram.model.addNodeData({ key: lane, isGroup: true });
        group = this.diagram.findNodeForKey(lane);
      }
      if (horiz) {
        group.location = new go.Point(
          -this.layerSpacing / 2,
          this.lanePositions.get(lane) * this.columnSpacing +
            offset.y,
        );
      } else {
        group.location = new go.Point(
          this.lanePositions.get(lane) * this.columnSpacing +
            offset.x,
          -this.layerSpacing / 2,
        );
      }
      var ph = group.findObject("PLACEHOLDER"); // won't be a go.Placeholder, but just a regular Shape
      if (ph === null) ph = group;
      if (horiz) {
        ph.desiredSize = new go.Size(
          totallength,
          this.laneBreadths.get(lane) * this.columnSpacing,
        );
      } else {
        ph.desiredSize = new go.Size(
          this.laneBreadths.get(lane) * this.columnSpacing,
          totallength,
        );
      }
    }
  }
}  // end CustomSwimLaneLayout

      let myDiagram;

      function init() {
        const $ = go.GraphObject.make;
        myDiagram = $(go.Diagram, "myDiagramDiv", {
          layout: $(CustomSwimLaneLayout, {
            laneProperty: "group",
            layerSpacing: 20
          })
        });

        myDiagram.nodeTemplate =
          $(go.Node, "Spot",
            new go.Binding("location", "loc", go.Point.parse),
            $(go.Shape, "RoundedRectangle", {
              fill: "white",
              width: 100,
              height: 50,
              portId: "",
              fromLinkable: true,
              toLinkable: true,
              cursor: "pointer",
            }),
            $(go.TextBlock, // the text label
              new go.Binding("text", "key"),
              { verticalAlignment: go.Spot.Center, textAlign: "center" }),
          );

        myDiagram.linkTemplate =
          $(go.Link,
            {
              curve: go.Link.Bezier,
              toShortLength: 8,
              fromEndSegmentLength: 50,
              toEndSegmentLength: 50,
            },
            $(go.Shape, { isPanelMain: true, strokeWidth: 2 }),
            $(go.Shape, { toArrow: "Standard", stroke: null }),
          );

        myDiagram.groupTemplate =
          $(go.Group, "Horizontal",
            {
              layerName: "Background",
              movable: false,
              copyable: false,
              locationObjectName: "PLACEHOLDER",
              layout: null,
              avoidable: false,
            },
            $(go.TextBlock,
              { font: "bold 12pt sans-serif", angle: 270 },
              new go.Binding("text", "key"),
            ),
            $(go.Panel, "Auto",
              $(go.Shape, { fill: "transparent", stroke: "orange" }),
              $(go.Shape, {
                name: "PLACEHOLDER",
                fill: null,
                stroke: null,
                strokeWidth: 0,
              })
            )
          );

        const nodeDataArray = [
          {
            key: "A",
            isGroup: true,
          },
          {
            key: "Box1",
            group: "A",
          },
          {
            key: "Box2",
            group: "A",
          },
          {
            key: "Box3",
            group: "A",
          },
          {
            key: "Box4",
            group: "A",
          },
          {
            key: "Box5",
            group: "A",
          },
        ];

        const linkDataArray = [
          {
            from: "Box1",
            to: "Box2",
          },
          {
            from: "Box1",
            to: "Box3",
          },
          {
            from: "Box1",
            to: "Box4",
          },
          {
            from: "Box2",
            to: "Box5",
          },
        ];

        const model = new go.GraphLinksModel();
        model.nodeDataArray = nodeDataArray;
        model.linkDataArray = linkDataArray;
        myDiagram.model = model;

        myDiagram.addDiagramListener("SelectionMoved", (event) => {
          const node = event.subject.first();
          console.log(node.data);
        });
      }

      window.addEventListener("DOMContentLoaded", init);

      document.getElementById("reorder3").addEventListener("click", (e) => {
        myDiagram.commit(d => {
          const data2 = d.model.findNodeDataForKey("Box2");
          const data3 = d.model.findNodeDataForKey("Box3");
          d.model.set(data2, "text", "2");  // sorted by the "text" value,
          d.model.set(data3, "text", "1");  // in this case so that Box2 comes after Box3
          d.layoutDiagram(true);
        });
      });
    </script>
  </body>
</html>

Thank you so much, Walter! Let me check out the code and study it. Will keep you posted if I have more questions :) Really appreciate your help!