Modifying the Comment sample

I have been trying to make use of the comment node as per the following example.
Comments (gojs.net)

I appreciate this is not a supported feature, but hoping you can clear up some confusion for me.

  1. I have set up a goJS palette with a small SVG icon to represent the comment node. When I drag the comment node onto the diagram, it creates an unconnected node which looks like a simple rectangle. It doesn’t look like a comment node because its missing the link. When I connect the node to another one we’re all good. Is it possible to make the node, automatically add a small unconnected link when I drop it on the diagram?

I set the diagram feature: “linkingTool.isUnconnectedLinkValid”: true, which allows the unconnected link. However I can’t seem to make that unconnected link appear when I drop the node onto the diagram. I tried creating the link in: myDiagram.addModelChangedListener, using:

e.model.addLinkData( {from: e.newValue.key, to: "", category: "calloutlink" });

however that seemed to set up a recursive loop which kept creating more comment nodes

  1. After I drop the comment node on the diagram I would like to be able to drag it around and have the link move with it so it looks like a single node (imagine speech/callout bubble in powerpoint). This way I can use this both as a unconnected comment as well as a connected comment.

  2. At the moment I can add comment nodes in the model array and connect them to a “standard” node as per the example. I setup a node template called “callout” and a link template called “linkCallout”. I also have created a nodeTemplateMap , and a linkTemplateMap for the diagram. This works fine when specifying nodes at design time using the diagram model. However when I create a callout comment node interactively, I want the system to use the callout link template when I connect to another node. Its as if I want to associate the callout template for the node with the call out link template. I can’t see how to do this.

  3. There appears to be tiny bug in the Balloon link extension, which has the effect of making the callout link very small as you size the node, to make bigger. If you want a very large comment node than the link almost looks like a single line - instead of an arrow. Looking at the code, it appears that this occurs because the arrow link geometry is computed from the node centre using the “_base” attribute.

This is what it looks like:

I have made a small modification, to prevent this from happening, by scaling this base attribute using the size of the node. This is achieved with the following code inserted just before the variables L and R are computed as follows:

let ilp = this.getLinkPointFromPoint(this.fromNode, this.fromNode, p0, pn, true);

// scale the arrow base to prevent the arrowhead width at the intersection with the node from changing as the comment node is resized
var D = Math.sqrt(Math.pow(pn.x-p0.x, 2) + Math.pow(pn.y-p0.y, 2) );
var d = Math.sqrt(Math.pow(ilp.x-pn.x, 2) + Math.pow(ilp.y-pn.y, 2) );
var scaledBase = this.base * D/d;

I then use this scaledBase variable to change the computation of L and R here:

var L = new go.Point(scaledBase /*this.base*/, 0).rotate(ang - 90).add(p0);
var R = new go.Point(scaledBase /*this.base*/, 0).rotate(ang + 90).add(p0);

Perhaps you have a better way, but I offer this, by way of contribution, as maybe others have the same issue.

The first thing I noticed when trying the scenario you envision is that the BalloonLink extension doesn’t work with partially disconnected Links. If you want the BalloonLink.makeGeometry to work when Link.toNode is null, modify your copy of the BalloonLink extension:

BalloonLink.prototype.makeGeometry = function() {
  // assume the fromNode is the comment and the toNode is the commented-upon node
  var bb = this.fromNode.actualBounds;
  if (this.toNode === null) {
    var nb = new go.Rect(bb.right + 50, bb.bottom + 50, 0, 0);
  } else {
    var nb = this.toNode.actualBounds;
  }
  . . .

Of course you can play with the “50” constants there to suit your tastes.

If you are handling a drag-and-drop from a Palette onto a regular Diagram, you’ll want to implement an “ExternalObjectsDropped” DiagramEvent listener that checks the newly copied Parts that were dropped, and for each “Comment” Node that it finds it adds a “Comment” Link the is connected from that “Comment” Node.

Here’s my modification of the “Comments” sample, in addition to the change you need to make to the BalloonLink extension:

    function init() {

      // Since 2.2 you can also author concise templates with method chaining instead of GraphObject.make
      // For details, see https://gojs.net/latest/intro/buildingObjects.html
      const $ = go.GraphObject.make;

      function addCommentLinks(map, options) {
        const links = [];
        map.iteratorKeys.each(n => {
          if (n instanceof go.Node && n.category === "Comment") {
            n.findLinksOutOf().each(l => {
              if (l.toNode === null && l.category === "Comment") links.push(l);
            });
          }
        });
        links.forEach(l => map.add(l, new go.DraggingInfo()));
        return map;
      }

      myDiagram =
        $(go.Diagram, "myDiagramDiv",  // create a Diagram for the DIV HTML element
          {
            layout: $(go.TreeLayout,
              {
                angle: 90,
                setsPortSpot: false,
                setsChildPortSpot: false
              }),
            "undoManager.isEnabled": true,
            // When a Node is deleted by the user, also delete all of its Comment Nodes.
            // When a Comment Link is deleted, also delete the corresponding Comment Node.
            "SelectionDeleting": e => {
              var parts = e.subject;  // the collection of Parts to be deleted, the Diagram.selection
              // iterate over a copy of this collection,
              // because we may add to the collection by selecting more Parts
              parts.copy().each(p => {
                if (p instanceof go.Node) {
                  var node = p;
                  node.findNodesConnected().each(n => {
                    // remove every Comment Node that is connected with this node
                    if (n.category === "Comment") {
                      n.isSelected = true;  // include in normal deletion process
                    }
                  });
                } else if (p instanceof go.Link && p.category === "Comment") {
                  var comlink = p;  // a "Comment" Link
                  var comnode = comlink.fromNode;
                  // remove the Comment Node that is associated with this Comment Link,
                  if (comnode.category === "Comment") {
                    comnode.isSelected = true;  // include in normal deletion process
                  }
                }
              });
            },
            "commandHandler.computeEffectiveCollection": function(parts, options) {
              const map = go.CommandHandler.prototype.computeEffectiveCollection.call(this, parts, options);  // call super method
              return addCommentLinks(map, options);
            },
            "draggingTool.computeEffectiveCollection": function(parts, options) {
              const map = go.DraggingTool.prototype.computeEffectiveCollection.call(this, parts, options);  // call super method
              return addCommentLinks(map, options);
            },
            "relinkingTool.isUnconnectedLinkValid": true,
            "linkingTool.isUnconnectedLinkValid": true,
            "linkingTool.direction": go.LinkingTool.ForwardsOnly,
            "linkingTool.insertLink": function(fromnode, fromport, tonode, toport) {
              this.archetypeLinkData = { category: fromnode.category === "Comment" ? "Comment" : "" };
              return go.LinkingTool.prototype.insertLink.call(this, fromnode, fromport, tonode, toport);  // call super method
            }
          });

      myDiagram.nodeTemplate =
        $("Node", "Auto",
          { toLinkable: true },
          $("Shape",
            { fill: "white" },
            new go.Binding("fill", "color")),
          $("TextBlock",
            { margin: 6 },
            new go.Binding("text", "key"))
        );

      myDiagram.linkTemplate =
        $("Link",
          { layerName: "Background" },
          $("Shape",
            { strokeWidth: 1.5 }),
          $("Shape",
            { toArrow: "Standard", stroke: null })
        );

      myDiagram.nodeTemplateMap.add("Comment",
        $(go.Node,  // this needs to act as a rectangular shape for BalloonLink,
          { background: "yellow" },  // which can be accomplished by setting the background.
          { fromLinkable: true },
          $(go.TextBlock,
            { stroke: "brown", margin: 6, fromLinkable: false },
            new go.Binding("text"))
        ));

      myDiagram.linkTemplateMap.add("Comment",
        // if the BalloonLink class has been loaded from the Extensions directory, use it
        $((typeof BalloonLink === "function" ? BalloonLink : go.Link),
          { relinkableTo: true },
          $(go.Shape,  // the Shape.geometry will be computed to surround the comment node and
            // point all the way to the commented node
            { stroke: "brown", strokeWidth: 1, fill: "lightyellow" })
        ));

      myDiagram.model =
        new go.GraphLinksModel(
          {
            nodeDataArray: [
              { key: "Alpha", color: "orange" },
              { key: "Beta", color: "lightgreen" },
              { key: "Gamma", color: "lightgreen" },
              { key: "Delta", color: "pink" },
              { key: "A comment", text: "comment\nabout Alpha", category: "Comment" },
              { key: "B comment", text: "comment\nabout Beta", category: "Comment" },
              { key: "G comment", text: "comment about Gamma", category: "Comment" }
            ],
            linkDataArray: [
              { from: "Alpha", to: "Beta" },
              { from: "Alpha", to: "Gamma" },
              { from: "Alpha", to: "Delta" },
              { from: "A comment", to: "Alpha", category: "Comment" },
              { from: "B comment", to: "Beta", category: "Comment" },
              { from: "G comment", to: "Gamma", category: "Comment" }
            ]
          });

      // show the model in JSON format
      document.getElementById("savedModel").textContent = myDiagram.model.toJson();
    }
    window.addEventListener('DOMContentLoaded', init);

Diff this code with that in Comments to see what I have added to address your first three questions.

I haven’t looked at your fourth comment/question. Thanks for investigating it.

For the next release, 2.2.1, we have improved the BalloonLink geometry and also added a BalloonLink.corner property. It will probably be released next week. If you want to try it now, I could post the source code here.

Thanks Walter, this is great. And it almost works for me with one small exception:
When I drag the comment node onto the diagram, it doesn’t display the unconnected link.

I have confirmed that the link is there by inspecting the data model (I did as you suggested and added it using ExternalObjectsDropped listener) . However it is not displayed. It appears as if it is not invoking BalloonLink.prototype.makeGeometry until you manually draw a link?

Hmmm, that case seems to need more work…

Here’s an update from the current beta 2.2.1 of BalloonLink.makeGeometry:

BalloonLink.prototype.makeGeometry = function() {
  if (this.fromNode === null) return new go.Geometry();
  // assume the fromNode is the comment and the toNode is the commented-upon node
  var bb = this.fromNode.actualBounds.copy().addMargin(this.fromNode.margin);

  var pn = this.pointsCount === 0 ? bb.center : this.getPoint(this.pointsCount - 1);
  if (this.toNode !== null && bb.intersectsRect(this.toNode.actualBounds)) {
    pn = this.toNode.actualBounds.center;
  } else if (this.toNode === null && this.pointsCount === 0) {
    pn = new go.Point(bb.centerX, bb.bottom + 50);
  }

  . . . var base = Math.max(0, this.base); . . .

And here’s my derivation of the Comments sample that lets users drag-and-drop comments from a Palette and copy and paste them too.

<html>
<body>
  <script src="../release/go.js"></script>
  <script src="../extensions/BalloonLink.js"></script>
  <script id="code">
function init() {

  // Since 2.2 you can also author concise templates with method chaining instead of GraphObject.make
  // For details, see https://gojs.net/latest/intro/buildingObjects.html
  const $ = go.GraphObject.make;

  myDiagram =
    $(go.Diagram, "myDiagramDiv",  // create a Diagram for the DIV HTML element
      {
        "animationManager.isEnabled": false,
        layout: $(go.TreeLayout,
          {
            isOngoing: false,
            angle: 90,
            setsPortSpot: false,
            setsChildPortSpot: false
          }),
        "undoManager.isEnabled": true,
        // When a Node is deleted by the user, also delete all of its Comment Nodes.
        // When a Comment Link is deleted, also delete the corresponding Comment Node.
        "SelectionDeleting": e => {
          var parts = e.subject;  // the collection of Parts to be deleted, the Diagram.selection
          // iterate over a copy of this collection,
          // because we may add to the collection by selecting more Parts
          parts.copy().each(p => {
            if (p instanceof go.Node) {
              var node = p;
              node.findNodesConnected().each(n => {
                // remove every Comment Node that is connected with this node
                if (n.category === "Comment") {
                  n.isSelected = true;  // include in normal deletion process
                }
              });
            } else if (p instanceof go.Link && p.category === "Comment") {
              var comlink = p;  // a "Comment" Link
              var comnode = comlink.fromNode;
              // remove the Comment Node that is associated with this Comment Link,
              if (comnode.category === "Comment") {
                comnode.isSelected = true;  // include in normal deletion process
              }
            }
          });
        },
        "commandHandler.computeEffectiveCollection": function(parts, options) {
          const map = go.CommandHandler.prototype.computeEffectiveCollection.call(this, parts, options);  // call super method
          return gatherCommentLinks(map, options);
        },
        "draggingTool.computeEffectiveCollection": function(parts, options) {
          const map = go.DraggingTool.prototype.computeEffectiveCollection.call(this, parts, options);  // call super method
          return gatherCommentLinks(map, options);
        },
        "relinkingTool.isUnconnectedLinkValid": true,
        "linkingTool.isUnconnectedLinkValid": true,
        "linkingTool.direction": go.LinkingTool.ForwardsOnly,
        "linkingTool.insertLink": function(fromnode, fromport, tonode, toport) {
          this.archetypeLinkData = { category: fromnode.category === "Comment" ? "Comment" : "" };
          return go.LinkingTool.prototype.insertLink.call(this, fromnode, fromport, tonode, toport);  // call super method
        },
        "SelectionCopied": avoidBareComments,
        "ClipboardPasted": avoidBareComments,
        "ExternalObjectsDropped": avoidBareComments,
        "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();
          }
        }
      });

  function gatherCommentLinks(map, options) {
    const links = [];
    map.iteratorKeys.each(n => {
      if (n instanceof go.Node && n.category === "Comment") {
        n.findLinksOutOf().each(l => {
          if (l.toNode === null && l.category === "Comment") links.push(l);
        });
      }
    });
    links.forEach(l => map.add(l, new go.DraggingInfo()));
    return map;
  }

  function avoidBareComments(e) {
    e.diagram.selection.each(node => {
      if (node instanceof go.Node && node.category === "Comment" && node.linksConnected.count === 0) {
        const ldata = { category: "Comment", from: node.key };
        e.diagram.model.addLinkData(ldata);
        const link = e.diagram.findLinkForData(ldata);
        if (link) link.defaultToPoint = node.location.copy().offset(50, 50);
      }
    });
  }

  myDiagram.nodeTemplate =
    $("Node", "Auto",
      { toLinkable: true },
      $("Shape",
        { fill: "white" },
        new go.Binding("fill", "color")),
      $("TextBlock",
        { margin: 6, editable: true },
        new go.Binding("text").makeTwoWay())
    );

  myDiagram.linkTemplate =
    $("Link",
      { layerName: "Background" },
      $("Shape",
        { strokeWidth: 1.5 }),
      $("Shape",
        { toArrow: "Standard", stroke: null })
    );

  myDiagram.nodeTemplateMap.add("Comment",
    $(go.Node,
      {
        background: "transparent",
        margin: 3,  // used by BalloonLink.makeGeometry
        fromLinkable: true, cursor: "pointer"  // to allow drawing new "Comment" Links to nodes or to nowhere
      },
      $(go.TextBlock,
        { stroke: "brown", margin: 6, fromLinkable: false, editable: true },
        new go.Binding("text").makeTwoWay())
    ));

  myDiagram.linkTemplateMap.add("Comment",
    $(BalloonLink,
      { base: 15, corner: 10, relinkableTo: true },
      $(go.Shape,  // the Shape.geometry will be computed to surround the comment node and
        // point all the way to the commented node
        { stroke: "brown", strokeWidth: 1, fill: "lightyellow" })
    ));

  myDiagram.model =
    new go.GraphLinksModel(
      {
        nodeDataArray: [
          { key: 1, text: "Alpha", color: "orange" },
          { key: 2, text: "Beta", color: "lightgreen" },
          { key: 3, text: "Gamma", color: "lightgreen" },
          { key: 4, text: "Delta", color: "pink" },
          { key: -1, text: "comment\nabout Alpha", category: "Comment" },
          { key: -2, text: "comment\nabout Beta", category: "Comment" },
          { key: -3, text: "comment about Gamma", category: "Comment" }
        ],
        linkDataArray: [
          { from: 1, to: 2 },
          { from: 1, to: 3 },
          { from: 1, to: 4 },
          { from: -1, to: 1, category: "Comment" },
          { from: -2, to: 2, category: "Comment" },
          { from: -3, to: 3, category: "Comment" }
        ]
      });

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

  // initialize Overview
  myOverview =
    $(go.Overview, "myOverviewDiv",
      {
        observed: myDiagram,
        contentAlignment: go.Spot.Center
      });
}
window.addEventListener('DOMContentLoaded', init);
  </script>

  <div id="sample">
    <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>
    <div style="display: inline">
      Current Diagram.model saved in JSON format:<br />
      <pre id="mySavedModel"></pre>
    </div>
  </div>
</body>
</html>

Thanks Walter. Can you please paste the whole of new Beta 2.2.1 of BalloonLink.js. It seems there’s other changes in the file. Just using that little snippet does not work for me.

"use strict";
/*
*  Copyright (C) 1998-2022 by Northwoods Software Corporation. All Rights Reserved.
*/

// A custom Link that draws a "balloon" shape around the Link.fromNode

/*
* This is an extension and not part of the main GoJS library.
* Note that the API for this class may change with any version, even point releases.
* If you intend to use an extension in production, you should copy the code to your own source directory.
* Extensions can be found in the GoJS kit under the extensions or extensionsTS folders.
* See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
*/

/**
* @constructor
* @extends Link
* @class
* This custom Link class customizes its Shape to surround the comment node.
* If the Shape is filled, it will obscure the comment itself unless the Link is behind the comment node.
* Thus the default layer for BalloonLinks is "Background".
*
* The "corner" property controls the radius of the curves at the corners of the rectangular area surrounding the comment node,
* rather than the curve at corners along the route, which is always straight.
* The default value is 10.
*/
function BalloonLink() {
  go.Link.call(this);
  this.layerName = "Background";
  this._base = 15;
  this.corner = 10;
  this.defaultToPoint = new go.Point(0, 0);
}
go.Diagram.inherit(BalloonLink, go.Link);

/**
* @ignore
* Copies properties to a cloned BalloonLink.
* @this {BalloonLink}
* @param {BalloonLink} copy
* @override
*/
BalloonLink.prototype.cloneProtected = function(copy) {
  go.Link.prototype.cloneProtected.call(this, copy);
  copy._base = this._base;
}

/*
* The approximate width of the base of the triangle at the Link.fromNode.
* The default value is 15.
* The actual width of the base, especially at corners and with unusual geometries, may vary from this property value.
* @name BalloonLink#base
* @return {number}
*/
Object.defineProperty(BalloonLink.prototype, "base", {
  get: function() { return this._base; },
  set: function(value) { this._base = value; }
});

/**
* Produce a Geometry from the Link's route that draws a "balloon" shape around the Link.fromNode
* and has a triangular shape with the base at the fromNode and the top at the toNode.
* @this {BalloonLink}
*/
BalloonLink.prototype.makeGeometry = function() {
  if (this.fromNode === null) return new go.Geometry();
  // assume the fromNode is the comment and the toNode is the commented-upon node
  var bb = this.fromNode.actualBounds.copy().addMargin(this.fromNode.margin);

  var pn = this.pointsCount === 0 ? bb.center : this.getPoint(this.pointsCount - 1);
  if (this.toNode !== null && bb.intersectsRect(this.toNode.actualBounds)) {
    pn = this.toNode.actualBounds.center;
  } else if (this.toNode === null && this.pointsCount === 0) {
    pn = new go.Point(bb.centerX, bb.bottom + 50);
  }

  var base = Math.max(0, this.base);
  var corner = Math.min(Math.max(0, this.corner), Math.min(bb.width/2, bb.height/2));
  var cornerext = Math.min(base, corner + base/2);

  var fig = new go.PathFigure();
  var prevx = 0;
  var prevy = 0;

  // helper functions
  function start(x, y) {
    fig.startX = prevx = x;
    fig.startY = prevy = y;
  }
  function point(x, y, v, w) {
    fig.add(new go.PathSegment(go.PathSegment.Line, x, y));
    fig.add(new go.PathSegment(go.PathSegment.Line, v, w));
    prevx = v;
    prevy = w;
  }
  function turn(x, y) {
    if (prevx === x && prevy > y) {  // top left
      fig.add(new go.PathSegment(go.PathSegment.Line, x, y + corner));
      fig.add(new go.PathSegment(go.PathSegment.Arc, 180, 90, x+corner, y+corner, corner, corner));
    } else if (prevx < x && prevy === y) {  // top right
      fig.add(new go.PathSegment(go.PathSegment.Line, x - corner, y));
      fig.add(new go.PathSegment(go.PathSegment.Arc, 270, 90, x-corner, y+corner, corner, corner));
    } else if (prevx === x && prevy < y) {  // bottom right
      fig.add(new go.PathSegment(go.PathSegment.Line, x, y - corner));
      fig.add(new go.PathSegment(go.PathSegment.Arc, 0, 90, x-corner, y-corner, corner, corner));
    } else if (prevx > x && prevy === y) {  // bottom left
      fig.add(new go.PathSegment(go.PathSegment.Line, x + corner, y));
      fig.add(new go.PathSegment(go.PathSegment.Arc, 90, 90, x+corner, y-corner, corner, corner));
    } // else if prevx === x && prevy === y, no-op
    prevx = x;
    prevy = y;
  }

  if (pn.x < bb.left) {
    if (pn.y < bb.top) {
      start(bb.left, Math.min(bb.top + cornerext, bb.bottom - corner));
      point(pn.x, pn.y, Math.min(bb.left + cornerext, bb.right - corner), bb.top);
      turn(bb.right, bb.top); turn(bb.right, bb.bottom); turn(bb.left, bb.bottom);
    } else if (pn.y > bb.bottom) {
      start(Math.min(bb.left + cornerext, bb.right - corner), bb.bottom);
      point(pn.x, pn.y, bb.left, Math.max(bb.bottom - cornerext, bb.top + corner));
      turn(bb.left, bb.top); turn(bb.right, bb.top); turn(bb.right, bb.bottom);
    } else {  // pn.y >= bb.top && pn.y <= bb.bottom
      var y = Math.min(Math.max(pn.y + base/3, bb.top + corner + base), bb.bottom - corner);
      start(bb.left, y);
      point(pn.x, pn.y, bb.left, Math.max(y-base, bb.top + corner));
      turn(bb.left, bb.top); turn(bb.right, bb.top); turn(bb.right, bb.bottom); turn(bb.left, bb.bottom);
    }
  } else if (pn.x > bb.right) {
    if (pn.y < bb.top) {
      start(Math.max(bb.right - cornerext, bb.left + corner), bb.top);
      point(pn.x, pn.y, bb.right, Math.min(bb.top + cornerext, bb.bottom - corner));
      turn(bb.right, bb.bottom); turn(bb.left, bb.bottom); turn(bb.left, bb.top);
    } else if (pn.y > bb.bottom) {
      start(bb.right, Math.max(bb.bottom - cornerext, bb.top + corner));
      point(pn.x, pn.y, Math.max(bb.right - cornerext, bb.left + corner), bb.bottom);
      turn(bb.left, bb.bottom); turn(bb.left, bb.top); turn(bb.right, bb.top);
    } else {  // pn.y >= bb.top && pn.y <= bb.bottom
      var y = Math.min(Math.max(pn.y + base/3, bb.top + corner + base), bb.bottom - corner);
      start(bb.right, Math.max(y-base, bb.top + corner));
      point(pn.x, pn.y, bb.right, y);
      turn(bb.right, bb.bottom); turn(bb.left, bb.bottom); turn(bb.left, bb.top); turn(bb.right, bb.top);
    }
  } else {  // pn.x >= bb.left && pn.x <= bb.right
    var x = Math.min(Math.max(pn.x + base/3, bb.left + corner + base), bb.right - corner);
    if (pn.y < bb.top) {
      start(Math.max(x-base, bb.left + corner), bb.top);
      point(pn.x, pn.y, x, bb.top);
      turn(bb.right, bb.top); turn(bb.right, bb.bottom); turn(bb.left, bb.bottom); turn(bb.left, bb.top);
    } else if (pn.y > bb.bottom) {
      start(x, bb.bottom);
      point(pn.x, pn.y, Math.max(x-base, bb.left + corner), bb.bottom);
      turn(bb.left, bb.bottom); turn(bb.left, bb.top); turn(bb.right, bb.top); turn(bb.right, bb.bottom);
    } else { // inside
      start(bb.left, bb.top+bb.height/2);
      // no "point", just corners
      turn(bb.left, bb.top); turn(bb.right, bb.top); turn(bb.right, bb.bottom); turn(bb.left, bb.bottom);
    }
  }

  var geo = new go.Geometry();
  fig.segments.last().close();
  geo.add(fig);
  geo.offset(-this.routeBounds.x, -this.routeBounds.y);
  return geo;
};
// end BalloonLink class

Thanks Walter. This is perfect. Well almost…:)

I added a little modification to allow a gap between the comment pointer and the node because that is often how it would be drawn as follows.

image

I did this by including a local variable in the constructor : this._gap = 15;
And then added the following code after the initial calculation of pn

// Recompute pn to allow for gap between end of comment link and target node
var ang = pn.directionPoint(bb.center);  
let gapToTargetNode = new go.Point(this._gap,0).rotate(ang);
pn = new go.Point(pn.x+gapToTargetNode.x, pn.y+gapToTargetNode.y);
// End modification 

Works OK as long as the _gap is positive.
Was thinking this might be useful inclusion. Also, would be good if the gap can be negative (implying the comment pointer intersects the node) .

Anyway, I’m good with the way it works - thanks for the help, and like the improvements.

I think you don’t need to do that change – try setting the margin on the node.

Wouldn’t setting the margin on the node interfer with other links on the node?

Oops, never mind – you can set the margin on the “Comment” node to increase the space between the comment node and the bubble edge. Not at the regular “to” Node. Sorry about that.