Integrating the SnappingTool in an existing Diagram

Let’s say you had a more basic diagram such as the Flowchart example. I may be over-simplifying, but the Flowchart example uses the nodeTemplateMap to add some fairly basic nodes to the diagram/palette. These nodes are made up Panel objects, having Shape objects and TextBlock objects. These nodes can be connected by Link objects.

What I’d like to do is add the option of linking node directly to one another. Without getting into why one would do this, let’s imagine that I wanted to connect two “Step” nodes without having a Link between them.

I’ve seen the Pipes example, and this illustrates what I’m looking for. In this example, each Node uses Geometry to define the Shape. If I could apply the SnappingTool concept to “Step” node, that would solve my problem.

Unfortunately, the Flowchart example does not incorporate the SnappingTool and the Pipes example does not use string arguments such as “Rectangle” to define the Shape.

What I’m trying to do meshes the two concepts, but combining the two samples has been troublesome. Can the SnappingTool be applied in the way I’ve described, or can snapping only be applied to simple shapes?

Assuming I can re-use the custom SnappingTool class from the Pipes example, how would I apply it to the “Step” node in the Flowchart example?

I think that can be accomplished without too much effort, but I’m unable to work on it right now. If you can wait until next week, we might be able to demonstrate this for you.

Much appreciated!

OK, so I made a copy of FlowChart.html and inserted a copy of the SnappingTool:

  // Define a custom DraggingTool
  function SnappingTool() {
    go.DraggingTool.call(this);
  }
  go.Diagram.inherit(SnappingTool, go.DraggingTool);

  // This predicate checks to see if the ports can snap together.
  // The first letter of the port id should be "U", "F", or "M" to indicate which kinds of port may connect.
  // The second letter of the port id should be a digit to indicate which direction it may connect.
  // The ports also need to not already have any link connections and need to face opposite directions.
  SnappingTool.prototype.compatiblePorts = function(p1, p2) {
    // already connected?
    var part1 = p1.part;
    var id1 = p1.portId;
    if (id1 === null || id1 === "") return false;
    if (part1.findLinksConnected(id1).count > 0) return false;
    var part2 = p2.part;
    var id2 = p2.portId;
    if (id2 === null || id2 === "") return false;
    if (part2.findLinksConnected(id2).count > 0) return false;
    // compatible fittings?
    if ((id1 === 'B' && id2 === 'T') || (id1 === 'T' && id2 === 'B') ||
        (id1 === 'R' && id2 === 'L') || (id1 === 'L' && id2 === 'R')) {
      return true;
    }
    return false;
  };

  // Override this method to find the offset such that a moving port can
  // be snapped to be coincident with a compatible stationary port,
  // then move all of the parts by that offset.
  /** @override */
  SnappingTool.prototype.moveParts = function(parts, offset, check) {
    // when moving an actually copied collection of Parts, use the offset that was calculated during the drag
    if (this._snapOffset && this.isActive && this.diagram.lastInput.up && parts === this.copiedParts) {
      go.DraggingTool.prototype.moveParts.call(this, parts, this._snapOffset, check);
      this._snapOffset = undefined;
      return;
    }

    var commonOffset = offset;

    // find out if any snapping is desired for any Node being dragged
    var sit = parts.iterator;
    while (sit.next()) {
      var node = sit.key;
      if (!(node instanceof go.Node)) continue;
      var info = sit.value;
      var newloc = info.point.copy().add(offset);

      // now calculate snap point for this Node
      var snapoffset = newloc.copy().subtract(node.location);
      var nearbyports = null;
      var closestDistance = 20 * 20;  // don't bother taking sqrt
      var closestPort = null;
      var closestPortPt = null;
      var nodePort = null;
      var mit = node.ports;
      while (mit.next()) {
        var port = mit.value;
        if (node.findLinksConnected(port.portId).count > 0) continue;
        var portPt = port.getDocumentPoint(go.Spot.Center);
        portPt.add(snapoffset);  // where it would be without snapping

        if (nearbyports === null) {
          // this collects the Nodes that intersect with the NODE's bounds,
          // excluding nodes that are being dragged (i.e. in the PARTS collection)
          var nearbyparts = this.diagram.findObjectsIn(node.actualBounds,
                                    function(x) { return x.part; },
                                    function(p) { return !parts.contains(p); },
                                    true);

          // gather a collection of GraphObjects that are stationary "ports" for this NODE
          nearbyports = new go.Set(go.GraphObject);
          nearbyparts.each(function(n) {
            if (n instanceof go.Node) {
              nearbyports.addAll(n.ports);
            }
          });
        }

        var pit = nearbyports.iterator;
        while (pit.next()) {
          var p = pit.value;
          if (!this.compatiblePorts(port, p)) continue;
          var ppt = p.getDocumentPoint(go.Spot.Center);
          var d = ppt.distanceSquaredPoint(portPt);
          if (d < closestDistance) {
            closestDistance = d;
            closestPort = p;
            closestPortPt = ppt;
            nodePort = port;
          }
        }
      }

      // found something to snap to!
      if (closestPort !== null) {
        // move the node so that the compatible ports coincide
        var noderelpt = nodePort.getDocumentPoint(go.Spot.Center).subtract(node.location);
        var snappt = closestPortPt.copy().subtract(noderelpt);
        // save the offset, to ensure everything moves together
        commonOffset = snappt.subtract(newloc).add(offset);
        // ignore any node.dragComputation function
        // ignore any node.minLocation and node.maxLocation
        break;
      }
    }

    // now do the standard movement with the single (perhaps snapped) offset
    this._snapOffset = commonOffset.copy();  // remember for mouse-up when copying
    go.DraggingTool.prototype.moveParts.call(this, parts, commonOffset, check);
  };

  // Establish links between snapped ports,
  // and remove obsolete links because their ports are no longer coincident.
  /** @override */
  SnappingTool.prototype.doDropOnto = function(pt, obj) {
    go.DraggingTool.prototype.doDropOnto.call(this, pt, obj);
    var tool = this;
    // Need to iterate over all of the dropped nodes to see which ports happen to be snapped to stationary ports
    var coll = this.copiedParts || this.draggedParts;
    var it = coll.iterator;
    while (it.next()) {
      var node = it.key;
      if (!(node instanceof go.Node)) continue;
      // connect all snapped ports of this NODE (yes, there might be more than one) with links
      var pit = node.ports;
      while (pit.next()) {
        var port = pit.value;
        // maybe add a link -- see if the port is at another port that is compatible
        var portPt = port.getDocumentPoint(go.Spot.Center);
        if (!portPt.isReal()) continue;
        var nearbyports =
            this.diagram.findObjectsAt(portPt,
                      function(x) {  // some GraphObject at portPt
                        var o = x;
                        // walk up the chain of panels
                        while (o !== null && o.portId === null) o = o.panel;
                        return o;
                      },
                      function(p) {  // a "port" Panel
                        // the parent Node must not be in the dragged collection, and
                        // this port P must be compatible with the NODE's PORT
                        if (coll.contains(p.part)) return false;
                        var ppt = p.getDocumentPoint(go.Spot.Center);
                        if (portPt.distanceSquaredPoint(ppt) >= 0.25) return false;
                        return tool.compatiblePorts(port, p);
                      });
        // did we find a compatible port?
        var np = nearbyports.first();
        if (np !== null) {
          // connect the NODE's PORT with the other port found at the same point
          this.diagram.toolManager.linkingTool.insertLink(node, port, np.part, np);
        }
      }
    }
  };

  // Just move selected nodes when SHIFT moving, causing nodes to be unsnapped.
  // When SHIFTing, must disconnect all links that connect with nodes not being dragged.
  // Without SHIFT, move all nodes that are snapped to selected nodes, even indirectly.
  /** @override */
  SnappingTool.prototype.computeEffectiveCollection = function(parts) {
    if (this.diagram.lastInput.shift) {
      var links = new go.Set(go.Link);
      var coll = go.DraggingTool.prototype.computeEffectiveCollection.call(this, parts);
      coll.iteratorKeys.each(function(node) {
        // disconnect all links of this node that connect with stationary node
        if (!(node instanceof go.Node)) return;
        node.findLinksConnected().each(function(link) {
          // see if this link connects with a node that is being dragged
          var othernode = link.getOtherNode(node);
          if (othernode !== null && !coll.contains(othernode)) {
            links.add(link);  // remember for later deletion
          }
        });
      });
      // outside of nested loops we can actually delete the links
      links.each(function(l) { l.diagram.remove(l); });
      return coll;
    } else {
      var map = new go.Map(go.Part, Object);
      if (parts === null) return map;
      var tool = this;
      parts.iterator.each(function(n) {
        tool.gatherConnecteds(map, n);
      });
      return map;
    }
  };

  // Find other attached nodes.
  SnappingTool.prototype.gatherConnecteds = function(map, node) {
    if (!(node instanceof go.Node)) return;
    if (map.contains(node)) return;
    // record the original Node location, for relative positioning and for cancellation
    map.add(node, { point: node.location });
    // now recursively collect all connected Nodes and the Links to them
    var tool = this;
    node.findLinksConnected().each(function(link) {
      map.add(link, { point: new go.Point() });
      tool.gatherConnecteds(map, link.getOtherNode(node));
    });
  };
  // end SnappingTool class

I modified the compatiblePorts predicate to adapt to the different port naming convention used by the FlowChart sample.

I replaced the standard DraggingTool with an instance of this SnappingTool:

      $(go.Diagram, "myDiagramDiv",  // must name or refer to the DIV HTML element
        {
          . . .
          // use a custom DraggingTool instead of the standard one, defined below
          draggingTool: new SnappingTool(),
          . . .

And everything just worked. (To make it easier to test, I simply deleted all of the link data objects from the JSON-format model and then pressed “Load”.)

Note that Links are still shown as gray Shapes – you can change the template to be like the one in the Pipes sample if you always expect connected ports to always be at coincident positions.

Also, ports do not automatically hide and show depending on whether or not there is a link connected with that port. Again, you can adapt the linkConnected and linkDisconnected event handlers from the Pipes sample.

walter, thanks so much for your quick/insightful response. I was able to integrate the SnappingTool into the FlowCharts example just as you described and I can see what I was doing wrong in my implementation. I have a couple questions that hopefully you can answer.

First, you said…

I was wondering if the visibility of Links is an all-or-nothing scenario. As you know the FlowCharts example has Links visible by default, whereas the Pipes example has the Links turned off (allowLink: false). Is it possible to conditionally hide an individual Link?

Lastly, once two Nodes are “snapped” together via the SnappingTool, if you move/drag one Node, the other connected Node(s) will move along with it. This behavior was as expected. What I didn’t expect was that if you have two or more “snapped” Nodes that are also connected via a Link to another Node (or perhaps an entire flow chart), moving the “snapped” Nodes will move all connected objects. I’d like to be able to move the “snapped” Nodes autonomously, without having to move the entire flow chart.

I modified the gatherConnecteds function in a manner that sort of works, but was hoping there might be a more elegant way of doing it.

  // Find other attached nodes.
  SnappingTool.prototype.gatherConnecteds = function(map, node) {
    if (!(node instanceof go.Node)) return;
    if (map.contains(node)) return;
    // record the original Node location, for relative positioning and for cancellation
    map.add(node, { point: node.location });
    // now recursively collect all connected Nodes and the Links to them
    var tool = this;
    var snapCount = 0;
    node.findLinksConnected().each(function(link) {
      if (snapCount < 1) {
		  map.add(link, { point: new go.Point() });
		  tool.gatherConnecteds(map, link.getOtherNode(node));
		  snapCount++;
      }
    });

Any ideas? Thanks again!!

You can set or bind Link.visible or Link.opacity or Shape.visible or Shape.stroke or any other property to control the appearance and behavior, as you wish.

Note that setting Diagram.allowLink to false will prevent the user from drawing new links using the LinkingTool. If you wanted to show or hide all of the Links, it might be easiest to put them all in their own Layer, by setting Link.layerName in the template. Then you can set that layer’s Layer.opacity to 1.0 or 0.0, as you choose.

Ah, yes, I didn’t know if you wanted to continue having links connect ports that were not coincident. Yes, you can achieve that effect most easily by ignoring links whose ports are not coincident.

    // now recursively collect all connected Nodes and the Links to them
    var tool = this;
    node.findLinksConnected().each(function(link) {
      var fp = link.fromPort.getDocumentPoint(go.Spot.Center);  //***
      var tp = link.toPort.getDocumentPoint(go.Spot.Center);    //***
      if (fp.distanceSquaredPoint(tp) > 0.5) return;            //***
      map.add(link, { point: new go.Point() });
      tool.gatherConnecteds(map, link.getOtherNode(node));
    });

Note the three lines I added, marked with “//***”. I haven’t actually tried this code, so pardon me if there are any errors in it.

Works flawlessly, thank you!

This is the last piece of the puzzle for me. Essentially, the Diagram needs the Links to be visible for all Node-to-Node connections except for cases where the Nodes are “snapped” together. In this scenario, I’d like to hide the Link or make it transparent.

From what I can tell, the Link itself is generated in the SnappingTool.prototype.doDropOnto function, via the following line:

this.diagram.toolManager.linkingTool.insertLink(node, port, np.part, np);

If that is the case, where would I be able to infuse Link.visible or Link.opacity?

I also experimented with defining those parameters in the linkDataArray, but that didn’t work for me. Can you tell me if the API describes what can/can’t be defined in the linkDataArray? I looked, but couldn’t find anything. Is it just limited to any properties that can be attributed to a Link (or that of the Classes it inherits from)? I tried adding "opacity": 0.0 to the linkDataArray for the Link in question, but that seemed to have no effect.

Well, as you can see in the documentation, LinkingTool | GoJS API, that method returns a Link.

You can use whatever properties you want on your model node or link data objects. Whatever you need for your app. There are a few properties that by convention are used by the model itself, but you can specify the names that are used by setting model properties named “…Property”.

If you want to use Model.toJson and Model.fromJson, you’ll want to make sure the property names and their values support JSON serialization, as described in the documentation for those methods. But otherwise you can put whatever you like on your model data.

You should not put references to Diagram or Tool or Node or Link or GraphObject in your model. That’s good practice for all model-view architectures.

Read more at: GoJS Using Models -- Northwoods Software and GoJS Data Binding -- Northwoods Software

Because I needed to make the Links resulting from “snapping” to be hidden/invisible, I created a linkTemplateMap (called “Hidden”) and set the stroke for this category of Link to “transparent”. To do this, I modified the prototype for the doDropOnto function of the custom SnappingTool extension.

    // did we find a compatible port?
    var np = nearbyports.first();
    if (np !== null) {
      // connect the NODE's PORT with the other port found at the same point
      //this.diagram.toolManager.linkingTool.insertLink(node, port, np.part, np);
      var link = this.diagram.toolManager.linkingTool.insertLink(node, port, np.part, np);
      link.category = "Hidden";
    }

This results in the linkDataArray having category values such as the following:

linkDataArray":[{“from”:-1,“to”:-5,“fromPort”:“R”,“toPort”:“L”,“text”:“”,“color”:“black”,“category”:null},

{“from”:-14,“to”:-16,“fromPort”:“R”,“toPort”:“L”,“text”:null,“color”:“black”,“category”:null},{“from”:-7,“to”:-8,“fromPort”:“R”,“toPort”:“L”,“text”:null,“color”:“black”,“category”:“Hidden”},{“from”:-8,“to”:-15,“fromPort”:“R”,“toPort”:“T”,“text”:null,“color”:“black”,“category”:null}]}

This seemed to work fine, but upon implementing the test code further, I started getting the following error when the Diagram loads:

Microsoft JScript runtime error: getCategoryForLinkData found a non-string category for [object Object]: null

Is it wrong to use the Link category in this way? Any suggestions?

Data category values must be strings. Typically they are used to look up a template in a Map. So null is not allowed. Use the empty string, "", instead as the default value, or don’t bother setting the property on the data at all.

Understood. Thanks!