Animation of incoming links not smooth after gojs upgrade

Hi,

After upgrading to v3.0.8, I have an issue with links redrawing during animation. This issue did not exist in v2.3.9 which is what I upgraded from. When I look for the problem, it seems like it is caused by different behavior related to the AvoidNodes router. I have routing in my link template set to AvoidNodes but when I change this to another router such as orthogonal, the issue no longer exists in v3.0.8.

Basically, as the layout changes and nodes and links animate to their new position, links are flickering to avoid nodes even during animation. I have a clip that shows what it looks like in our app and it does not look clean.

AnimationRegression

Here is the animation working as expected in v2.3.9:

AnimationWorking-v2.3.9

You can also see the issue, albeit not as pronounced by copying over the example from one of my previous posts. The code snippet is at the top of the topic, Drag and Drop Example Content Alignment.

Please let me know if a change is needed on my end or if a bug fix is required in gojs.

Thanks, we’ll have a look and get back to you.

That sample seems to have a related, but opposite problem: avoids node routing is not occuring at all, even after the animation has finished:

There’s definitely at least one issue here.

Hmmm, that is odd. I do not see that issue on my end at all, routing is working for me after animation has finished. Just to be on the same page, I did update the script to target 3.0.8. Here is the full blown snippet on my end, updated with 3.0.8 scripts.

<!DOCTYPE html>
<html>
  <head>
    <title>Splice Node into Link with DropZone</title>
    <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->
  </head>

  <body>
    <div style="width: 100%; display: flex; justify-content: space-between">
      <div style="display: flex; flex-direction: column; margin: 0 2px 0 0">
        <div id="myPaletteDiv" style="flex-grow: 1; width: 100px; background-color: floralwhite; border: solid 1px black"></div>
        <div id="myOverviewDiv" style="margin: 2px 0 0 0; width: 100px; height: 100px; background-color: whitesmoke; border: solid 1px black"></div>
      </div>
      <div id="myDiagramDiv" style="flex-grow: 1; height: 400px; border: solid 1px black"></div>
    </div>
    <textarea id="mySavedModel" style="width: 100%; height: 250px"></textarea>

    <script src="https://unpkg.com/[email protected]/release/go.js"></script>
    <script src="https://unpkg.com/[email protected]/dist/extensions/NonRealtimeDraggingTool.js"></script>
    <script id="code">
      class DropZoneDraggingTool extends NonRealtimeDraggingTool {
        constructor(init) {
          super();
          this.subtreeOffset = new go.Point(100, 0);
          const $ = go.GraphObject.make;
          this.DropZone = $(
            go.Adornment,
            "Auto",
            { locationSpot: go.Spot.Center },
            $(go.Shape, "RoundedRectangle", { fill: "white", strokeWidth: 5, stroke: "lightblue" }),
            $(
              go.Panel,
              "Auto",
              { width: 100, height: 60, margin: 5 },
              $(go.Shape, "RoundedRectangle", { fill: "white", stroke: "lightblue", strokeDashArray: [4, 2] }),
              $(
                go.Panel,
                "Spot",
                $(go.Shape, { fill: "white", stroke: "lightgray", strokeDashArray: [4, 2], width: 30, height: 20 }),
                $(go.Shape, {
                  alignment: new go.Spot(0.5, 0.5, 0, -10),
                  geometryString: "M5 0L5 20 M0 15 L5 20 10 15",
                  stroke: "lightgray",
                  strokeWidth: 2,
                })
              )
            )
          );
          this.DropZone.ensureBounds();
          // internal state
          this._subtree = null;
          this._wasAvoidsNodes = false;
          this._draggedNode = null;
          if (init) Object.assign(this, init);
        }

        // User must drag a single Node
        findDraggablePart() {
          if (this.diagram.selection.count > 1) return null;
          const part = super.findDraggablePart();
          if (part instanceof go.Node) {
            this._draggedNode = part;
            return part;
          }
          return null;
        }

        // Show a DropZone if dragging over a Link (other than one connected with _draggedNode)
        doDragOver(pt, obj) {
          // method override
          // find Part at PT, ignoring temporary Parts except for the DropZone (temporary because it's an Adornment)
          var trgt = this.diagram.findObjectAt(
            pt,
            (x) => x.part,
            (x) => x === this.DropZone || !x.layer.isTemporary
          );
          if (trgt instanceof go.Link) {
            const link = trgt;
            if (link.fromNode === this._draggedNode || link.toNode === this._draggedNode) return;
            if (this.DropZone.adornedPart === null) {
              const mid = link.routeBounds.center;
              const needsShift = link.routeBounds.width < this.DropZone.actualBounds.width + 100 || link.routeBounds.height < this.DropZone.actualBounds.height + 100;
              const oldskips = this.diagram.skipsUndoManager;
              this.diagram.skipsUndoManager = true;
              if (needsShift) {
                this._wasAvoidsNodes = link.routing === go.Link.AvoidsNodes;
                if (this._wasAvoidsNodes) link.routing = go.Link.Orthogonal;
                this._subtree = link.toNode.findTreeParts();
                // shift subtree rightward
                this.diagram.moveParts(this._subtree, this.subtreeOffset);
              }
              link.isHighlighted = true;
              this.DropZone.adornedObject = link;
              link.addAdornment("DropZone", this.DropZone);
              this.DropZone.location = new go.Point(mid.x + 50, mid.y);
              this.diagram.skipsUndoManager = oldskips;
            }
          } else if (trgt !== this.DropZone) {
            this.cleanup();
          }
        }

        cleanup() {
          const link = this.DropZone.adornedPart;
          if (link) {
            const oldskips = this.diagram.skipsUndoManager;
            this.diagram.skipsUndoManager = true;
            if (this._subtree) {
              // shift subtree leftward
              this.diagram.moveParts(this._subtree, new go.Point(-this.subtreeOffset.x, -this.subtreeOffset.y));
              this._subtree = null;
              if (this._wasAvoidsNodes) link.routing = go.Link.AvoidsNodes;
              this._wasAvoidsNodes = false;
            }
            link.isHighlighted = false;
            link.removeAdornment("DropZone");
            this.DropZone.adornedObject = null;
            this.diagram.skipsUndoManager = oldskips;
          }
        }

        // If dropped into DropZone, splice it into the corresponding Link
        // (Note, not using doDropOnto due to undo problems.
        // Overriding doMouseUp means needing "ExternalObjectsDropped" listener too,
        // duplicating some of the work.)
        doMouseUp() {
          // method override
          const link = this.DropZone.adornedPart;
          const node = this._draggedNode;
          const pt = this.diagram.lastInput.documentPoint;
          const trgt = this.diagram.findObjectAt(
            pt,
            (x) => x.part,
            (x) => x === this.DropZone
          );
          if (trgt === this.DropZone) {
            this.cleanup();
            this.spliceIntoLink(link, node);
          } else {
            this.cleanup();
          }
          super.doMouseUp();
        }

        // Splice the _draggedNode into the dropped-onto Link
        spliceIntoLink(link, node) {
          if (!link || !node) return;
          const diag = this.diagram;
          if (!diag) return;
          // disconnect node being dropped (copy collection to avoid iterating over modifications)
          new go.List(node.findLinksConnected()).each((l) => diag.remove(l));
          const to = link.toNode;
          const linkdata = {};
          diag.model.addLinkData(linkdata);
          const newlink = diag.findLinkForData(linkdata);
          if (newlink !== null) {
            // splice in that node
            link.toNode = node;
            newlink.fromNode = node;
            newlink.toNode = to;
          }
        }

        doDeactivate() {
          this._subtree = null;
          this._draggedNode = null;
          super.doDeactivate();
        }

        doCancel() {
          this.cleanup();
          super.doCancel();
        }
      }

      const $ = go.GraphObject.make;

      const myDiagram = new go.Diagram("myDiagramDiv", {
        contentAlignment: go.Spot.Center,
        layout: new go.TreeLayout(),
        // install the replacement DraggingTool:
        draggingTool: new DropZoneDraggingTool({ duration: 400 }),
        ExternalObjectsDropped: (e) => {
          const tool = e.diagram.toolManager.draggingTool;
          const pt = e.diagram.lastInput.documentPoint;
          const trgt = e.diagram.findObjectAt(
            pt,
            (x) => x.part,
            (x) => x === tool.DropZone
          );
          if (trgt === tool.DropZone) {
            const link = tool.DropZone.adornedPart;
            const node = e.diagram.selection.first();
            tool.cleanup();
            tool.spliceIntoLink(link, node);
          }
        },
        "undoManager.isEnabled": true,
        ModelChanged: (e) => {
          // just for demonstration purposes,
          if (e.isTransactionFinished) {
            // show the model data in the page's TextArea
            document.getElementById("mySavedModel").textContent = e.model.toJson();
          }
        },
      });

      myDiagram.nodeTemplate = $(go.Node, "Auto", { locationSpot: go.Spot.Center }, $(go.Shape, { fill: "white" }, new go.Binding("fill", "color")), $(go.TextBlock, { margin: 8 }, new go.Binding("text")));

      myDiagram.linkTemplate = $(
        go.Link,
        { routing: go.Link.AvoidsNodes, reshapable: true },
        // the highlight path Shape
        $(
          go.Shape,
          { isPanelMain: true, strokeWidth: 7, stroke: "transparent" },
          // when highlighted, show this thick Shape in red
          new go.Binding("stroke", "isHighlighted", (h) => (h ? "red" : "transparent")).ofObject()
        ),
        // the normal path Shape
        $(go.Shape, { isPanelMain: true, strokeWidth: 1.5 }),
        $(go.Shape, { toArrow: "OpenTriangle" })
      );

      myDiagram.model = new go.GraphLinksModel(
        [
          { key: 1, text: "Alpha", color: "lightblue" },
          { key: 3, text: "Gamma", color: "lightgreen" },
          { key: 4, text: "Delta", color: "pink" },
          { key: 5, text: "Epsilon", color: "yellow" },
        ],
        [
          { from: 1, to: 3 },
          { from: 3, to: 4 },
          { from: 4, to: 5 },
        ]
      );

      // initialize Palette
      myPalette = new go.Palette("myPaletteDiv", {
        nodeTemplateMap: myDiagram.nodeTemplateMap,
        model: new go.GraphLinksModel([
          { text: "red node", color: "red" },
          { text: "green node", color: "green" },
          { text: "blue node", color: "blue" },
          { text: "orange node", color: "orange" },
        ]),
      });

      myPalette.toolManager.draggingTool.doCancel = function () {
        myDiagram.toolManager.draggingTool.doCancel();
        go.DraggingTool.prototype.doCancel.call(this);
      };

      // initialize Overview
      myOverview = new go.Overview("myOverviewDiv", {
        observed: myDiagram,
        contentAlignment: go.Spot.Center,
      });
    </script>
  </body>
</html>

Hm this is weird. If I take that sample and increase the duration to more easily see effects:

draggingTool: new DropZoneDraggingTool({ duration: 4000 }),

It works the same between 2.3 and 3.0 except that 2.3 (incorrectly) does not avoid at the end of the drag, and 3.0 does. Try swapping in:

    <script src="https://unpkg.com/[email protected]/release/go.js"></script>
    <script src="https://unpkg.com/[email protected]/extensions/NonRealtimeDraggingTool.js"></script>

And you’ll see. Both versions have links that avoid nodes in real-time (which I agree is not great). Live example of both

Right now, the DraggingTool and ResizingTool use an internal flag to suspect routing during their operations. Maybe we should generalize that to allow users more control over it.


However, I think this is slightly different from the behavior we are diagnosing in the original post (the “Split” node creation animation), which is clearly different between 2.3 and 3.0 and I’m pretty sure I found the bug responsible.

If you try this provisional build of GoJS in your app, does it work as you’d expect? https://gojs.net/temp/go3010a.js

I tried but I am not sure of a clean way to use the provisional build for my app in particular. My app is tightly integrated with gojs-react so even when I was able to make use of the provisional gojs as a script, I’m not sure how to handle the react library in order to get the app to compile. When I try to load that library as well from a CDN, I’m not sure how to use it together and if the CDN of gojs-react would integrate with the provisional build.

I did try it with example I shared above and it looked like it solved the problem, at least there. Is there anyway the provisional build could be made available via npm as a beta version i.e. 3.0.0-b8?

What happens if you try using 3.0.4? My suspicion is that we fixed a regression, so that may be a simpler test. Though other related routing problems may have been fixed in between.

It did fix a lot of the issue actually, but I still see a minor difference. I’ll get some videos where the animation is slowed down.

It’s definitely improved by downgrading from 3.0.8 to 3.0.4 but I still think 2.3.9 behaves ideally animation wise. You can see that there still is a slight jump with the bottom right link in 3.0.4.

This is small enough of a difference that we wouldn’t hold upgrading but, if it’s fixable, we would appreciate having it like 2.3.9 with no jumping during animation when link has routing set to AvoidsNodes. The jumping at faster animation speeds looks more like a flicker which we would like to avoid. Worth noting that jump doesn’t appear when link template does not have routing set to AvoidsNodes.

One last minor difference I just noticed. You can see at the very end of the animation, the bottom right link in the newer versions seem to need an extra transition to curved corners where as 2.3.9 smoothly transitions into curved corners. Small thing but maybe it can help explain the underlying difference in behavior.

v3.0.8
Animation-v3.0.8-SlowMo

v3.0.4
Animation-v3.0.4-SlowMo

v2.3.9
Animation-v2.3.9-SlowMo

Hi, any update on this and is there an expected timeline for the next minor release with fixes for the issues I’ve raised? I got some people asking about timeline on my end about the major version upgrade since they really would like to take advantage of the link routing enhancements. I am holding off until these issues are resolved in the next release.

I don’t mean to pressure in any way, but would appreciate some insight so I have a good grasp on timing on my end, even a rough estimate would be good. Thank you very much as always!

Sorry about that – for personal reasons we’ve had some delays. I am expecting that we’ll investigate this later this week.

Thank you for the update

Is it possible to give me the code that is creating this animation in the first place? There is an issue with AvoidsNodes running during this animation, so I’d like to recreate it, or just something like it. I don’t necessary need the code, just the operations that you’re doing when the button is pressed.

Why, for example, does the top “+” start so close to its final relative position versus the bottom “+”? Probably you can make the animation look a lot better if you configure the initial positions differently.

As for:

the bottom right link in the newer versions seem to need an extra transition to curved corners where as 2.3.9 smoothly transitions into curved corners

I think that’s just an artifact of the number of segments in the link transitioning to fewer at the last moment. (The extra segments are due to avoidsnodes)

I will answer with more detail at the end of the day but I’ll answer your specific question about ‘+’ node (aka add node) placement now.

The reason the top add node doesn’t transition much is because it is not a new node. When I add the split node, I am splicing it in between the audience node and the existing add node that had the dropdown. When a split node is added to the canvas, I also add any additional add nodes needed. I am not specifying initial positioning of newly added nodes.

My split node has a single “to port”, but multiple “from ports”. Depending on the number of paths configured for the split node, that will drive the number of from ports and outgoing add nodes.

As for the number of segments comment, could you clarify if it is normal for there to be a difference in AvoidsNodes routing in v2.3.9 vs v3? I do have AvoidsNodes in both situations. Is the router itself that is different or is it that v3 does routing even during animation now? Unclear what is the exact cause of the difference.

Ok, I looked at the code and what I said above is for the most part all of the operations within the add split node transaction you are seeing in the animation. Here is a list of the involved operations:

  • Add split node
  • Remove existing link between audience and add node
  • Add two new links, one from audience into split and one from split’s first port into existing add node
  • For each split port of the new split node not including the first, add a new add node and a new link connecting the port and new add node

The code is not that easy to separate out since there are many other types of interactions supported in my canvas outside of this transaction. There is shared code that the transaction takes advantage of.

However, if need be, I can look into creating a sample. I got other things on my plate at the moment though so I would appreciate it if an explicit sample is not necessary.

I’m pretty sure we’ve fixed this, we’ll release soon and you can give it a try.

Ok, we’ve just released 3.0.10, which should fix this, and hopefully other issues you’ve reported.

Ok, I’ll take a look hopefully tomorrow or Monday. Thanks

I did the upgrade and I can confirm everything works now and all of the reported bugs are fixed, including this animation one.

I did have to make a change related to fromSegmentLength and toSegmentLength which did not play as well with the upgrade. I did have them at likely too high of a value, each at 50. Lowering to 20 solved the issue. I also did see an issue if I did not set them at all when I was testing.

I can share my findings if you’d like but I should be all set on my end given nothing unexpected comes up. Thank you very much!

Are you using LayeredDigraphLayout? Did you not have its packOption set (setting it is unusual)? Then for v3 you are using the newer layout algorithm that is controlled by LayeredDigraphLayout.alignOption, which has probably affected the spacing and alignment between nodes.