Extending Dynamic Ports Link Routing Overlapping Issue

Hi All. I am extending the Dynamic Ports example and am having trouble understanding the link routing. See the attached image. I need the horizontal routes to not overlap. I am using AvoidsNodes and the CustomLink as in the example. I have played around with .curviness to see if I can affect the routing to no avail.

Any pointers?

That functionality is not built into GoJS, but it might be possible to do better than the behavior you are getting so far. I can look into this starting tomorrow. I’ll start from the Dynamic Ports sample.

OK, try this unsupported and inefficient solution. I have only changed the sample to show a new model that is like what you show in your screenshot, and to make use of some additional code that tries to reroute orthogonal links to avoid overlapping link segments. That code does not try to make sure any new routes do not overlap other nodes.

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Dynamic Ports</title>
  <meta name="description" content="Nodes with varying lists of ports on each of four sides." />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- Copyright 1998-2020 by Northwoods Software Corporation. -->

  <script src="go.js"></script>
  <script src="AvoidsLinksRouter.js"></script>
  <script id="code">
    function init() {
      var $ = go.GraphObject.make;  //for conciseness in defining node templates

      myDiagram =
        $(go.Diagram, "myDiagramDiv",  //Diagram refers to its DIV HTML element by id
          {
            "LayoutCompleted": function(e) { myRouter.avoidOrthogonalOverlaps(e.diagram.links); },
            "SelectionMoved": function(e) { myRouter.avoidOrthogonalOverlaps(e.diagram.links); },
            "undoManager.isEnabled": true
          });
      var myRouter = new AvoidsLinksRouter();

      // when the document is modified, add a "*" to the title and enable the "Save" button
      myDiagram.addDiagramListener("Modified", function(e) {
        var button = document.getElementById("SaveButton");
        if (button) button.disabled = !myDiagram.isModified;
        var idx = document.title.indexOf("*");
        if (myDiagram.isModified) {
          if (idx < 0) document.title += "*";
        } else {
          if (idx >= 0) document.title = document.title.substr(0, idx);
        }
      });

      // To simplify this code we define a function for creating a context menu button:
      function makeButton(text, action, visiblePredicate) {
        return $("ContextMenuButton",
          $(go.TextBlock, text),
          { click: action },
          // don't bother with binding GraphObject.visible if there's no predicate
          visiblePredicate ? new go.Binding("visible", "", function(o, e) { return o.diagram ? visiblePredicate(o, e) : false; }).ofObject() : {});
      }

      var nodeMenu =  // context menu for each Node
        $("ContextMenu",
          makeButton("Copy",
            function(e, obj) { e.diagram.commandHandler.copySelection(); }),
          makeButton("Delete",
            function(e, obj) { e.diagram.commandHandler.deleteSelection(); }),
          $(go.Shape, "LineH", { strokeWidth: 2, height: 1, stretch: go.GraphObject.Horizontal }),
          makeButton("Add top port",
            function(e, obj) { addPort("top"); }),
          makeButton("Add left port",
            function(e, obj) { addPort("left"); }),
          makeButton("Add right port",
            function(e, obj) { addPort("right"); }),
          makeButton("Add bottom port",
            function(e, obj) { addPort("bottom"); })
        );

      var portSize = new go.Size(8, 8);

      var portMenu =  // context menu for each port
        $("ContextMenu",
          makeButton("Swap order",
            function(e, obj) { swapOrder(obj.part.adornedObject); }),
          makeButton("Remove port",
            // in the click event handler, the obj.part is the Adornment;
            // its adornedObject is the port
            function(e, obj) { removePort(obj.part.adornedObject); }),
          makeButton("Change color",
            function(e, obj) { changeColor(obj.part.adornedObject); }),
          makeButton("Remove side ports",
            function(e, obj) { removeAll(obj.part.adornedObject); })
        );

      // the node template
      // includes a panel on each side with an itemArray of panels containing ports
      myDiagram.nodeTemplate =
        $(go.Node, "Table",
          {
            locationObjectName: "BODY",
            locationSpot: go.Spot.Center,
            selectionObjectName: "BODY",
            contextMenu: nodeMenu
          },
          new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),

          // the body
          $(go.Panel, "Auto",
            {
              row: 1, column: 1, name: "BODY",
              stretch: go.GraphObject.Fill
            },
            $(go.Shape, "Rectangle",
              {
                fill: "#dbf6cb", stroke: null, strokeWidth: 0,
                minSize: new go.Size(60, 60)
              }),
            $(go.TextBlock,
              { margin: 10, textAlign: "center", font: "bold 14px Segoe UI,sans-serif", stroke: "#484848", editable: true },
              new go.Binding("text", "name").makeTwoWay())
          ),  // end Auto Panel body

          // the Panel holding the left port elements, which are themselves Panels,
          // created for each item in the itemArray, bound to data.leftArray
          $(go.Panel, "Vertical",
            new go.Binding("itemArray", "leftArray"),
            {
              row: 1, column: 0,
              itemTemplate:
                $(go.Panel,
                  {
                    _side: "left",  // internal property to make it easier to tell which side it's on
                    fromSpot: go.Spot.Left, toSpot: go.Spot.Left,
                    fromLinkable: true, toLinkable: true, cursor: "pointer",
                    contextMenu: portMenu
                  },
                  new go.Binding("portId", "portId"),
                  $(go.Shape, "Rectangle",
                    {
                      stroke: null, strokeWidth: 0,
                      desiredSize: portSize,
                      margin: new go.Margin(1, 0)
                    },
                    new go.Binding("fill", "portColor"))
                )  // end itemTemplate
            }
          ),  // end Vertical Panel

          // the Panel holding the top port elements, which are themselves Panels,
          // created for each item in the itemArray, bound to data.topArray
          $(go.Panel, "Horizontal",
            new go.Binding("itemArray", "topArray"),
            {
              row: 0, column: 1,
              itemTemplate:
                $(go.Panel,
                  {
                    _side: "top",
                    fromSpot: go.Spot.Top, toSpot: go.Spot.Top,
                    fromLinkable: true, toLinkable: true, cursor: "pointer",
                    contextMenu: portMenu
                  },
                  new go.Binding("portId", "portId"),
                  $(go.Shape, "Rectangle",
                    {
                      stroke: null, strokeWidth: 0,
                      desiredSize: portSize,
                      margin: new go.Margin(0, 1)
                    },
                    new go.Binding("fill", "portColor"))
                )  // end itemTemplate
            }
          ),  // end Horizontal Panel

          // the Panel holding the right port elements, which are themselves Panels,
          // created for each item in the itemArray, bound to data.rightArray
          $(go.Panel, "Vertical",
            new go.Binding("itemArray", "rightArray"),
            {
              row: 1, column: 2,
              itemTemplate:
                $(go.Panel,
                  {
                    _side: "right",
                    fromSpot: go.Spot.Right, toSpot: go.Spot.Right,
                    fromLinkable: true, toLinkable: true, cursor: "pointer",
                    contextMenu: portMenu
                  },
                  new go.Binding("portId", "portId"),
                  $(go.Shape, "Rectangle",
                    {
                      stroke: null, strokeWidth: 0,
                      desiredSize: portSize,
                      margin: new go.Margin(1, 0)
                    },
                    new go.Binding("fill", "portColor"))
                )  // end itemTemplate
            }
          ),  // end Vertical Panel

          // the Panel holding the bottom port elements, which are themselves Panels,
          // created for each item in the itemArray, bound to data.bottomArray
          $(go.Panel, "Horizontal",
            new go.Binding("itemArray", "bottomArray"),
            {
              row: 2, column: 1,
              itemTemplate:
                $(go.Panel,
                  {
                    _side: "bottom",
                    fromSpot: go.Spot.Bottom, toSpot: go.Spot.Bottom,
                    fromLinkable: true, toLinkable: true, cursor: "pointer",
                    contextMenu: portMenu
                  },
                  new go.Binding("portId", "portId"),
                  $(go.Shape, "Rectangle",
                    {
                      stroke: null, strokeWidth: 0,
                      desiredSize: portSize,
                      margin: new go.Margin(0, 1)
                    },
                    new go.Binding("fill", "portColor"))
                )  // end itemTemplate
            }
          )  // end Horizontal Panel
        );  // end Node

      // an orthogonal link template, reshapable and relinkable
      myDiagram.linkTemplate =
        $(CustomLink,  // defined below
          {
            routing: go.Link.AvoidsNodes,
            corner: 4,
            curve: go.Link.JumpGap,
            reshapable: true,
            resegmentable: true,
            relinkableFrom: true,
            relinkableTo: true
          },
          new go.Binding("points").makeTwoWay(),
          $(go.Shape, { stroke: "#2F4F4F", strokeWidth: 2 })
        );

      // support double-clicking in the background to add a copy of this data as a node
      myDiagram.toolManager.clickCreatingTool.archetypeNodeData = {
        name: "Unit",
        leftArray: [],
        rightArray: [],
        topArray: [],
        bottomArray: []
      };

      myDiagram.contextMenu =
        $("ContextMenu",
          makeButton("Paste",
            function(e, obj) { e.diagram.commandHandler.pasteSelection(e.diagram.toolManager.contextMenuTool.mouseDownPoint); },
            function(o) { return o.diagram.commandHandler.canPasteSelection(o.diagram.toolManager.contextMenuTool.mouseDownPoint); }),
          makeButton("Undo",
            function(e, obj) { e.diagram.commandHandler.undo(); },
            function(o) { return o.diagram.commandHandler.canUndo(); }),
          makeButton("Redo",
            function(e, obj) { e.diagram.commandHandler.redo(); },
            function(o) { return o.diagram.commandHandler.canRedo(); })
        );

      // load the diagram from JSON data
      load();
    }


    // This custom-routing Link class tries to separate parallel links from each other.
    // This assumes that ports are lined up in a row/column on a side of the node.
    function CustomLink() {
      go.Link.call(this);
    };
    go.Diagram.inherit(CustomLink, go.Link);

    CustomLink.prototype.findSidePortIndexAndCount = function(node, port) {
      var nodedata = node.data;
      if (nodedata !== null) {
        var portdata = port.data;
        var side = port._side;
        var arr = nodedata[side + "Array"];
        var len = arr.length;
        for (var i = 0; i < len; i++) {
          if (arr[i] === portdata) return [i, len];
        }
      }
      return [-1, len];
    };

    CustomLink.prototype.computeEndSegmentLength = function(node, port, spot, from) {
      var esl = go.Link.prototype.computeEndSegmentLength.call(this, node, port, spot, from);
      var other = this.getOtherPort(port);
      if (port !== null && other !== null) {
        var thispt = port.getDocumentPoint(this.computeSpot(from));
        var otherpt = other.getDocumentPoint(this.computeSpot(!from));
        if (Math.abs(thispt.x - otherpt.x) > 20 || Math.abs(thispt.y - otherpt.y) > 20) {
          var info = this.findSidePortIndexAndCount(node, port);
          var idx = info[0];
          var count = info[1];
          if (port._side == "top" || port._side == "bottom") {
            if (otherpt.x < thispt.x) {
              return esl + 4 + idx * 8;
            } else {
              return esl + (count - idx - 1) * 8;
            }
          } else {  // left or right
            if (otherpt.y < thispt.y) {
              return esl + 4 + idx * 8;
            } else {
              return esl + (count - idx - 1) * 8;
            }
          }
        }
      }
      return esl;
    };

    CustomLink.prototype.hasCurviness = function() {
      if (isNaN(this.curviness)) return true;
      return go.Link.prototype.hasCurviness.call(this);
    };

    CustomLink.prototype.computeCurviness = function() {
      if (isNaN(this.curviness)) {
        var fromnode = this.fromNode;
        var fromport = this.fromPort;
        var fromspot = this.computeSpot(true);
        var frompt = fromport.getDocumentPoint(fromspot);
        var tonode = this.toNode;
        var toport = this.toPort;
        var tospot = this.computeSpot(false);
        var topt = toport.getDocumentPoint(tospot);
        if (Math.abs(frompt.x - topt.x) > 20 || Math.abs(frompt.y - topt.y) > 20) {
          if ((fromspot.equals(go.Spot.Left) || fromspot.equals(go.Spot.Right)) &&
            (tospot.equals(go.Spot.Left) || tospot.equals(go.Spot.Right))) {
            var fromseglen = this.computeEndSegmentLength(fromnode, fromport, fromspot, true);
            var toseglen = this.computeEndSegmentLength(tonode, toport, tospot, false);
            var c = (fromseglen - toseglen) / 2;
            if (frompt.x + fromseglen >= topt.x - toseglen) {
              if (frompt.y < topt.y) return c;
              if (frompt.y > topt.y) return -c;
            }
          } else if ((fromspot.equals(go.Spot.Top) || fromspot.equals(go.Spot.Bottom)) &&
            (tospot.equals(go.Spot.Top) || tospot.equals(go.Spot.Bottom))) {
            var fromseglen = this.computeEndSegmentLength(fromnode, fromport, fromspot, true);
            var toseglen = this.computeEndSegmentLength(tonode, toport, tospot, false);
            var c = (fromseglen - toseglen) / 2;
            if (frompt.x + fromseglen >= topt.x - toseglen) {
              if (frompt.y < topt.y) return c;
              if (frompt.y > topt.y) return -c;
            }
          }
        }
      }
      return go.Link.prototype.computeCurviness.call(this);
    };
    // end CustomLink class


    // Add a port to the specified side of the selected nodes.
    function addPort(side) {
      myDiagram.startTransaction("addPort");
      myDiagram.selection.each(function(node) {
        // skip any selected Links
        if (!(node instanceof go.Node)) return;
        // compute the next available index number for the side
        var i = 0;
        while (node.findPort(side + i.toString()) !== node) i++;
        // now this new port name is unique within the whole Node because of the side prefix
        var name = side + i.toString();
        // get the Array of port data to be modified
        var arr = node.data[side + "Array"];
        if (arr) {
          // create a new port data object
          var newportdata = {
            portId: name,
            portColor: getPortColor()
            // if you add port data properties here, you should copy them in copyPortData above
          };
          // and add it to the Array of port data
          myDiagram.model.insertArrayItem(arr, -1, newportdata);
        }
      });
      myDiagram.commitTransaction("addPort");
    }

    // Exchange the position/order of the given port with the next one.
    // If it's the last one, swap with the previous one.
    function swapOrder(port) {
      var arr = port.panel.itemArray;
      if (arr.length >= 2) {  // only if there are at least two ports!
        for (var i = 0; i < arr.length; i++) {
          if (arr[i].portId === port.portId) {
            myDiagram.startTransaction("swap ports");
            if (i >= arr.length - 1) i--;  // now can swap I and I+1, even if it's the last port
            var newarr = arr.slice(0);  // copy Array
            newarr[i] = arr[i + 1];  // swap items
            newarr[i + 1] = arr[i];
            // remember the new Array in the model
            myDiagram.model.setDataProperty(port.part.data, port._side + "Array", newarr);
            myDiagram.commitTransaction("swap ports");
            break;
          }
        }
      }
    }

    // Remove the clicked port from the node.
    // Links to the port will be redrawn to the node's shape.
    function removePort(port) {
      myDiagram.startTransaction("removePort");
      var pid = port.portId;
      var arr = port.panel.itemArray;
      for (var i = 0; i < arr.length; i++) {
        if (arr[i].portId === pid) {
          myDiagram.model.removeArrayItem(arr, i);
          break;
        }
      }
      myDiagram.commitTransaction("removePort");
    }

    // Remove all ports from the same side of the node as the clicked port.
    function removeAll(port) {
      myDiagram.startTransaction("removePorts");
      var nodedata = port.part.data;
      var side = port._side;  // there are four property names, all ending in "Array"
      myDiagram.model.setDataProperty(nodedata, side + "Array", []);  // an empty Array
      myDiagram.commitTransaction("removePorts");
    }

    // Change the color of the clicked port.
    function changeColor(port) {
      myDiagram.startTransaction("colorPort");
      var data = port.data;
      myDiagram.model.setDataProperty(data, "portColor", getPortColor());
      myDiagram.commitTransaction("colorPort");
    }

    // Use some pastel colors for ports
    function getPortColor() {
      var portColors = ["#fae3d7", "#d6effc", "#ebe3fc", "#eaeef8", "#fadfe5", "#6cafdb", "#66d6d1"]
      return portColors[Math.floor(Math.random() * portColors.length)];
    }

    // Save the model to / load it from JSON text shown on the page itself, not in a database.
    function save() {
      document.getElementById("mySavedModel").value = myDiagram.model.toJson();
      myDiagram.isModified = false;
    }
    function load() {
      myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);

      // When copying a node, we need to copy the data that the node is bound to.
      // This JavaScript object includes properties for the node as a whole, and
      // four properties that are Arrays holding data for each port.
      // Those arrays and port data objects need to be copied too.
      // Thus Model.copiesArrays and Model.copiesArrayObjects both need to be true.

      // Link data includes the names of the to- and from- ports;
      // so the GraphLinksModel needs to set these property names:
      // linkFromPortIdProperty and linkToPortIdProperty.
    }
  </script>
</head>
<body onload="init()">
<div id="sample">
  <div id="myDiagramDiv" style="width:600px; height:500px; border:1px solid black"></div>
  Add port to selected nodes:
  <button onclick="addPort('top')">Top</button>
  <button onclick="addPort('bottom')">Bottom</button>
  <button onclick="addPort('left')">Left</button>
  <button onclick="addPort('right')">Right</button>
  <div>
    <div>
      <button id="SaveButton" onclick="save()">Save</button>
      <button onclick="load()">Load</button>
      Diagram Model saved in JSON format:
    </div>

    <textarea id="mySavedModel" style="width:100%;height:250px">
{ "class": "go.GraphLinksModel",
  "copiesArrays": true,
  "copiesArrayObjects": true,
  "linkFromPortIdProperty": "fromPort",
  "linkToPortIdProperty": "toPort",
  "nodeDataArray": [
{"key":1, "name":"Unit\nOne", "loc":"100 150",
"leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
 "topArray":[],
 "bottomArray":[],
 "rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] },
{"key":2, "name":"Unit\nTwo", "loc":"100 300",
 "leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
 "topArray":[],
 "bottomArray":[],
 "rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] },
{"key":3, "name":"Unit\nThree", "loc":"300 150",
 "leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
 "topArray":[],
 "bottomArray":[],
 "rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] },
{"key":4, "name":"Unit\nFour", "loc":"300 300",
 "leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
 "topArray":[],
 "bottomArray":[],
 "rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] }
 ],
  "linkDataArray": [
{"from":1, "to":2, "fromPort":"right0", "toPort":"left0"},
{"from":1, "to":2, "fromPort":"right1", "toPort":"left1"},
{"from":1, "to":2, "fromPort":"right2", "toPort":"left2"},
{"from":2, "to":1, "fromPort":"right0", "toPort":"left0"},
{"from":2, "to":1, "fromPort":"right1", "toPort":"left1"},
{"from":2, "to":1, "fromPort":"right2", "toPort":"left2"},
{"from":3, "to":4, "fromPort":"right0", "toPort":"left2"},
{"from":3, "to":4, "fromPort":"right1", "toPort":"left1"},
{"from":3, "to":4, "fromPort":"right2", "toPort":"left0"},
{"from":4, "to":3, "fromPort":"right0", "toPort":"left2"},
{"from":4, "to":3, "fromPort":"right1", "toPort":"left1"},
{"from":4, "to":3, "fromPort":"right2", "toPort":"left0"}
 ]}
    </textarea>
  </div>
</div>
</body>
</html>

Copy the unsupported and inefficient AvoidsLinksRouter.js file from https://gojs.net/temp/.

Thank you Walter, you clearly did not just throw that together. I appreciate the help.
The sample works great and is what I am after.
Once integrated in my code, the example breaks slightly. The moved selected objects are routed and rerouted back to their original positions. I clearly have some event somewhere that is putting things back but I can’t find it. All other links in the drawing are routed to avoid links.
Off to hunting for an errant event. Thanks again for this code!
CBH

It only took a few minutes to throw that together – really not a big deal. But remember that the code is not infallible.

It sounds like you have an error that is causing the move (i.e. DraggingTool’s operation) to be cancelled which causes the transaction to be rolled-back. Are you using go-debug.js and checking the console log?

Thanks for the tip. I will look in that direction.
CBH

There is now an improved AvoidsLinksRouter in version 3 of GoJS. For example: Dynamic Ports