Temporary Ports with No Real Port

We would like to be able to start dragging a link towards a node and create an input port to connect to automatically on that node if one is not available. I would like to show our “temporary port” if 1) there is not a port created yet or 2) there are ports, but they already have connections. (Ports in our world should only have one incoming connection, but a node can have multiple input ports if necessary). If the user decides to not link the two nodes, the temporary port goes away, or if they do, it should update our node model to have another port in its port item array.
Screenshot 2023-05-11 at 1.11.07 PM
Screenshot 2023-05-11 at 1.05.16 PM
Example: in the screen shots above, as I am dragging the second bottom link towards node 2, it adds a second “temporary input port” for the user to connect to.

I’ve started by looking within our custom LinkingTool- we have a “portTargeted” function that works for highlighting ports that are found, but I’m thinking that might be the right place for this functionality to exist as well. If a port is not found available in the portTargeted method, I search the area to see if a node is at least nearby, and what its port situation is like. This is the part where I’m struggling- it feels incorrect to dynamically create a port here, add it to our nodes itemarray, store a reference to it elsewhere, and then have to know to delete that port if the user decides to not connect to it when moving away. Before I went any further, I wanted to reach out and see if there are better suggestions for accomplishing this behavior.

Thanks!

Example code of what I’m thinking:

private portTarget(
    node: GoJSNode | null,
    port: GraphObject | null,
    _tempNode: GoJSNode,
    _tempPort: GraphObject,
    _toEnd: boolean
  ) {
    if (port !== null) {
       this.temporaryToPort = LinkTemplate.TemporaryToPort(port); // looks just like our normal ports, but with a black highlight
    } else {
      const node = this.diagram.findObjectsNear(this.diagram.lastInput.viewPoint, 2, (x) => {
        const p = x.part;
        return p instanceof GoJSNode ? p : null;
      }).first();
      if (node) { // there's a node near, see if we should display a temp port for them
        // 1) create temp port
        // 2) add temp port to node, save as class variable here for reference
        // 3) recall this method to find it and highlight?
      } else {
        // delete port reference here and from node, if the temp port reference here exists
      }
    }
  }

If I understand your requirements correctly, it seems that each Node has a default port that is used for all outgoing links. Presumably its fromLinkable property is true.

You would define an “LinkDrawn” DiagramEvent listener that adds a port to the “from” node (by calling Model.addArrayItem) and reconnects the newly drawn Link (i.e. the DiagramEvent.subject) to come from that new port by assigning its Link.fromPortId property.

So there would be no temporary output port at all. Your screenshots don’t seem to show a temporary port so I think what I suggest will still work visually.

I’m not sure what you want to do for input ports. Again, you could allow the drawing of a new link to an existing port, even though there’s already one link coming into it, and then fix it up in the “LinkDrawn” DiagramEvent listener by adding an input port and reconnecting the newly drawn Link.

Or you could use that same default port to also support any link that is coming in during the drawing of a new link. The same fix-up in the “LinkDrawn” event would apply.

Sorry I wasn’t clear about the criteria, here’s some information to hopefully help:

  • Circular nodes have links going to the body of the node/are the ports themselves; square nodes have square ports that have to be defined in order to connect them. You can’t connect to the body of a square node, and this temporary port business doesnt apply to circular nodes since they can’t add ports.
  • Square nodes can have an array of ports (0…n) defined on them on both sides. A user can add or remove as many as they want on square nodes, depending on the node. Some have restrictions like “can only have one input port” in which case we don’t want to let them to create and connect to another port.

The “temporary port” is just for input ports on the square node like in the screenshots, and each input port can only accept one link. In this case, as the user is dragging a link to node 2, a second port should appear for them to connect to since the first port is occupied. Before they start drawing that link, it looks like the first screen shot where there is only one port. The second port appears to let them know a link can be made.

I think the LinkDrawn event happens too late for this. Visually, we need the port to appear on the node as the user starts to drag to it to let them know that a connection can be made and is valid. UX is not a fan of letting them connect to one port and then splitting them a part after the fact.

Here’s an old sample demonstrating a variation on what I suggested: Automatically Creating Ports Just one type of node, though.

Ah, so that is why you were looking into LinkingBaseTool.portTargeted.
Still, it might not be needed. Take a look at Automatically Creating Ports

As always, the complete source code for each sample is in the page itself.

That’s the behavior I’m after. I think I can adapt this to work for us, thanks Walter!

Sorry to reopen an old issue; I’m picking back up this work for our app. I’ve been following this example you gave and trying to get it to work with our port behavior, described here.

The issue I’m facing is that I need all of the port locations to shift once the temporary port appears. The temporary port that we add needs to look and behave like a normal port would, which means all of the existing ports would need to shift their locations as if it were already a part of the item array. Do you have any suggestions for how to handle this?

Try this:

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <button id="myTestButton">Test</button>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">
class CustomLinkingTool extends go.LinkingTool {
  constructor(init) {
    super();
    this._hasTempPort = null;
    if (init) Object.assign(this, init);
  }

  // note: tempport is on the LinkingBaseTool.temporaryToNode, not on a real Node
  copyPortProperties(realnode, realport, tempnode, tempport, toend) {
    this.diagram.model.commit(m => {
      // if targeting the same node, don't need to remove any temporary port
      if (this._hasTempPort !== realnode) {
        this.cleanupAugmentedNode();
      }
      let tempid = "";
      if (this._hasTempPort === null) {
        const data = realnode.data;
        if (data) {
          tempid = "abcdefghijklmnopqrstuvwxyz"[data.ports.length];
          this.diagram.model.commit(m => {
            m.addArrayItem(data.ports, tempid);
          }, null);
          this._hasTempPort = realnode;
        }
      }
      if (this._hasTempPort !== null) {
        const data = this._hasTempPort.data;
        tempid = "abcdefghijklmnopqrstuvwxyz"[data.ports.length-1];
        const port = realnode.findPort(tempid);
        if (port !== null) {
          tempnode.location = port.getDocumentPoint(go.Spot.Center);
        }
      }
    }, null);
    super.copyPortProperties(realnode, realport, tempnode, tempport, toend);
  }

  setNoTargetPortProperties(tempnode, tempport, toend) {
    const augmentedNode = this._hasTempPort;
    if (augmentedNode !== null) {
      this._hasTempPort = null;
        const data = augmentedNode.data;
        if (data && data.ports.length > 0) {
          this.diagram.model.commit(m => {
            m.removeArrayItem(data.ports, data.ports.length-1);
          }, null);
        }
    }
    super.setNoTargetPortProperties(tempnode, tempport, toend);
  }

  cleanupAugmentedNode() {
    const augmentedNode = this._hasTempPort;
    if (augmentedNode !== null) {
      this._hasTempPort = null;
      const data = augmentedNode.data;
      if (data && data.ports.length > 0) {
        this.diagram.model.commit(m => {
          m.removeArrayItem(data.ports, data.ports.length-1);
        }, null);
      }
    }
  }

  insertLink(fromnode, fromport, tonode, toport) {
    this.cleanupAugmentedNode();
    if (tonode && tonode.data) {
      const data = tonode.data;
      const tempid = "abcdefghijklmnopqrstuvwxyz"[data.ports.length];
      if (toport.portId === tempid) {
        this.diagram.model.addArrayItem(data.ports, tempid);
      }
    }
    return super.insertLink(fromnode, fromport, tonode, toport);
  }

  doNoLink(fromnode, fromport, tonode, toport) {
    this.cleanupAugmentedNode();
    super.doNoLink(fromnode, romport, tonode, toport);
  }
}

const myDiagram =
  new go.Diagram("myDiagramDiv", {
      layout: new go.LayeredDigraphLayout(),
      linkingTool: new CustomLinkingTool(),
      "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 =
  new go.Node("Table", {
      layoutConditions: go.LayoutConditions.Standard & ~go.LayoutConditions.NodeSized
    })
    .add(
      new go.Panel("Auto", { column: 1, stretch: go.Stretch.Vertical, minSize: new go.Size(50, 50) })
        .add(
          new go.Shape({ fill: "white" })
            .bind("fill", "color"),
          new go.TextBlock({ margin: 8 })
            .bind("text")
        ),
      new go.Panel("Vertical", {
          column: 0,
          itemTemplate:
            new go.Panel({
                margin: new go.Margin(1, 0),
                toLinkable: true, toSpot: go.Spot.Left
              })
              .bind("portId", "")
              .add(
                new go.Shape({ width: 8, height: 8, fill: "gray" })
              )
        })
        .bind("itemArray", "ports"),
      new go.Shape({
          column: 2, width: 8, height: 8, fill: "gray",
          portId: "out", fromLinkable: true, fromSpot: go.Spot.Right, cursor: "pointer"
        })
    );

myDiagram.linkTemplate =
  new go.Link({
      fromPortId: "out", corner: 6,
      relinkableFrom: true, relinkableTo: true
    })
    .add(new go.Shape());

myDiagram.model = new go.GraphLinksModel({
  linkToPortIdProperty: "tid",
  nodeDataArray:
    [
      { key: 1, text: "Alpha", color: "lightblue", ports: ["a", "b", "c"] },
      { key: 2, text: "Beta", color: "orange", ports: ["a", "b", "c"] },
      { key: 3, text: "Gamma", color: "lightgreen", ports: ["a"] },
      { key: 4, text: "Delta", color: "pink", ports: ["a", "b", "c", "d"] }
    ],
  linkDataArray:
    [
      { from: 1, to: 2, tid: "a" },
      { from: 3, to: 4, tid: "c" }
    ]
});

document.getElementById("myTestButton").addEventListener("click", e => {
  
});
  </script>
</body>
</html>

I’ve taken that code and modified it to match what we’re needing, but am left with one remaining issue. The ports work as expected if the connection is being made; however, if no link is drawn, the node ends up recalculating the position of the ports incorrectly. I see where an extra port is making it’s way through to the templates, but I’m making sure to remove the port when no link is drawn. I see you have comments about making sure to modify the “real node” versus the template one, and I think that’s what I have here. setNoTargetPortProperties and copyPortProperties were both giving me different issues, so the code ended up moving into portTargeted for now.

Here’s the code as it stands, with some extra functions removed. If you can spot what’s causing the issue above, that would super helpful:

class CustomLinkingTool extends LinkingTool {
  private _userArchetypeLinkData: () => Palette;
  private _magicPortNode: GoJSNode | null;
  private _canAddPortDetails: ((node: Node, isInput: boolean) => Port | null) | null;

  public set canAddPortDetails(handler: ((node: Node, isInput: boolean) => Port | null) | null) {
    this._canAddPortDetails = handler;
  }

  public get canAddPortDetails() {
    return this._canAddPortDetails;
  }

  public set magicPortNode(port: GoJSNode | null) {
    this._magicPortNode = port;
  }

  public get magicPortNode() {
    return this._magicPortNode;
  }

  constructor(userArchetypeLinkData: () => Palette, validator?: LinkValidator) {
    super();
    this.direction = LinkingTool.ForwardsOnly;
    this.portGravity = 20;
    this._magicPortNode = null;
    this._canAddPortDetails = null;
    this.portTargeted = this.portTarget.bind(this);
  }

// some other overwritten styles functions I've removed for simplicity

  public insertLink(
    fromnode: GoJSNode | null,
    fromport: GraphObject | null,
    tonode: GoJSNode | null,
    toport: GraphObject | null
  ): GoJSLink | null {
    if (this.magicPortNode) {
     // the link was actually created to the magic port, so finish the transaction so undo/redo can capture the event
      this.diagram.commitTransaction(Transactions.ADD_PORT);
      this.magicPortNode = null;
    }
    return super.insertLink(fromnode, fromport, tonode, toport);
  }

  public doNoLink(
    fromnode: GoJSNode | null,
    fromport: GraphObject | null,
    tonode: GoJSNode | null,
    toport: GraphObject | null
  ): void {
    this.removeMagicPort();
    super.doNoLink(fromnode, fromport, tonode, toport);
  }

  private portTarget(
    node: GoJSNode | null,
    port: GraphObject | null,
    _tempNode: GoJSNode,
    _tempPort: GraphObject,
    _toEnd: boolean
  ) {
    if (port !== null) {
      // if it's a node and the ports are rolled up, unroll them
      if (
        node &&
        isNode(node.data) &&
        !node.data.inPorts.expanded &&
        node.data.inPorts.ports.length >= Constants.DEFAULT_ROLLUP_PORT_COUNT
      ) {
        this.diagram.model.setDataProperty(node.data.inPorts, 'expanded', true);
        node.updateTargetBindings();
      } else {
          this.temporaryToPort = LinkTemplate.TemporaryToPort(port);
      }
    } else {
      // magic ports; locate the target node
      const nodes = this.diagram.findObjectsNear(
        this.diagram.transformViewToDoc(this.diagram.lastInput.viewPoint),
        0.5,
        (x) => {
          const p = x.part;
          return p instanceof GoJSNode && p !== this.originalFromNode && p.data !== null ? p : null;
        }
      );
      // there's a node near, see if we should display a temp port for them
      if (nodes.size > 0 && !this.magicPortNode) {
        this.addMagicPort(nodes.iterator.first()!);
      } else if (nodes.size === 0 && this.magicPortNode) {
        this.removeMagicPort();
      }
    }
  }

  private addMagicPort(node: GoJSNode) {
    if (!this.canAddPortDetails) {
      // consumer doesnt want to allow magic ports
      return;
    }
    const availablePorts = Utilities.findAvailablePorts(node, true);
    if (availablePorts.length > 0) {
      return; // there's already connections that can be made
    }
    const dataNode = node.data;
    const canAddPort = this.canAddPortDetails(dataNode, true);
    if (canAddPort === null) {
      return; // user didn't allow this port to be made
    }

    const port: Port = PortManager.initPort(canAddPort);
    this.diagram.startTransaction(Transactions.ADD_PORT);
    this.diagram.model.addArrayItem(dataNode.inPorts.ports, port);
    if (dataNode.inPorts.ports.length > 2) {
      this.diagram.model.setDataProperty(dataNode.inPorts, 'expanded', true);
    }
    node.updateTargetBindings();

    this.magicPortNode = node;
  }

  private removeMagicPort() {
    if (this.magicPortNode === null) {
      return;
    }
    this.diagram.model.removeArrayItem(this.magicPortNode.data.inPorts.ports);
    this.diagram.rollbackTransaction(); // undo the start transaction for adding a port

    // reset node and link bindings to their proper locations now that a port has been officially removed
    this.magicPortNode.updateTargetBindings();

    this.magicPortNode = null;
  }
}

This is what’s happening when a link is not drawn:

  1. We’re targeting the second port on node nc from port ‘Oc’
  2. We decide to not make the connection and release the mouse click elsewhere. The node is recalculated with port ‘Ia’ and the connection incorrectly drawn.

    The link should be going to the port, and the single port should be straight from the node like this:

    Possibly worth mentioning that the moment you move the node, the port and link correct themselves

My code above didn’t handle undo correctly. I have updated that code. I suspect your adaptation of my code has the same bug in it.

But my test case didn’t have the problem that you pointed out in your code, so I’m not sure that that bug fix will be sufficient for you.

You’re correct, this isn’t the same problem. I spent some time upgrading our code to use version 3.0 and in the process, I realized the documentation for LinkingTool.portTargeted says that we shouldn’t be adding/removing any links, nodes, or port objects; however, in that portTargeted function I linked above, we are modifying the node itself to expand the ports so a user can make a connection. (If there are three or more ports, we collapse them down to appear as one port with a count on it.)

The debugger is throwing a binding error when a connection is not made, I believe due to trying to rollback that transaction of expanding the ports for the user. Is there a better place in the gojs framework that we should be expanding the ports on connections for the user?

This is what the transaction within our portTarget function looks like:

 this.diagram.model.commit(() => {
      // expand the ports so the user can see what they want to connect to
      this.diagram.model.setDataProperty(node.data.inPorts, 'expanded', true);
      // shift the node over so people are still pointing at the ports and not the node body
      this.diagram.model.setDataProperty(
        node.data,
        'location',
        Point.stringify(Point.parse(node.data.location).offset(Styles.EXPANDED_PORT_WIDTH, 0))
      );
      node.updateTargetBindings();
    }, 'Expand Ports On Target');

What happens when you make this change?

this.diagram.model.setDataProperty(node.data.inPorts, 'expanded', true);

In other words, what changes happen, presumably through bindings, but also anything else such as event handlers?

When we have three or more ports, we collapse the ports down to make it appear as one port with a number:


On port click, they can expand these ports to see them all:

That’s what the “expanded” property is controlling on the item array itself. The problem is that if someone starts to draw a connection to the node and the ports are in the collapsed view, we want to automatically expand the ports so they can make the connection. The code for the templates are largely the same as what’s here. There shouldn’t be any eventing happening when we do this.

The error I see from the gojs debugger is this:

Binding error: Error: PathSegment.ey must be a real number type, and not NaN or Infinity: NaN setting target property "geometry" on Shape(None)#27245 with conversion function: function squareIndexToGeoInput(i, shape) {
      var _shape$panel$panel$it, _shape$panel, _shape$panel$panel, _shape$panel$panel$it2;

The squareIndexToGeoInput is what we use to calculate the ports locations. It’s receiving NaN on the first iteration of trying to recalculate the node and ports after not making a connection.

Can you make that function tolerant of not-yet determined positions of those about-to-be-seen ports? Or calculate where they should be?

I was able to make the above function fault tolerant and get my code behaving the same way as yours. That said, I did find a bug in both of our code implementations- in your example, if you create a connection between nodes “Alpha” and “Gamma”, once all ports are taken no more will be automatically created if I try to create another connection between “Alpha” and “Gamma”. I believe that’s because the function isValidLink is returning false since duplicate connections are not allowed to already existing ports (which is the correct behavior for my app). If I try to alter the toLinkable... properties on the node templates, I feel like that gets me further away from the correct behavior. I’ve tried modifying the isValidLink function to only mark the node as valid if we can create a port automatically for the user, but that also creates some buggy behavior between the existing ports and the new one.

Any suggestions for a way around this? I can move this entire implementation to exist within the portTargeted function since that is always called by the GoJS code, but it seems like copyPortProperties and setNoTargetPortProperties are the correct places to be handling this temporary port behavior.

I don’t understand the intended behavior and what behavior you want when all of the input ports already each have a link coming into it.

Because what you describe sounds just like what I thought was intended.

The goal of adding an extra port for the user is for the scenario where all ports are in use and they need another. Users are allowed one connection to each input port and multiple leaving from output ports.

So in your example, If you draw a connection between Alpha and Gamma, that works. If you attempt to draw another connection between the two nodes (which is allowed in our app), another port does not appear on Gamma for the user because the isValidLink function assumes there is already a connection between the ports that exist and another is not allowed. We very specifically allow a user to have multiple connections between nodes if they are connected to different input ports. In your example, we allow multiple connections from Alpha to be drawn to Gamma as long as Gamma creates ports for each connection.

But if you set GraphObject.toMaxLinks to 1 on each port in the code I gave you on June 5th, Temporary Ports with No Real Port - #7 by walter, isn’t the behavior as you want? If not, how not?

I’ve edited your code below to demonstrate the issue a little more directly (added toMaxLinks:1, fixed a typo of fromport, and removed extra nodes)

<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <button id="myTestButton">Test</button>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">
class CustomLinkingTool extends go.LinkingTool {
  constructor(init) {
    super();
    this._hasTempPort = null;
    if (init) Object.assign(this, init);
  }

  // note: tempport is on the LinkingBaseTool.temporaryToNode, not on a real Node
  copyPortProperties(realnode, realport, tempnode, tempport, toend) {
    this.diagram.model.commit(m => {
      // if targeting the same node, don't need to remove any temporary port
      if (this._hasTempPort !== realnode) {
        this.cleanupAugmentedNode();
      }
      let tempid = "";
      if (this._hasTempPort === null) {
        const data = realnode.data;
        if (data) {
          tempid = "abcdefghijklmnopqrstuvwxyz"[data.ports.length];
          this.diagram.model.commit(m => {
            m.addArrayItem(data.ports, tempid);
          }, null);
          this._hasTempPort = realnode;
        }
      }
      if (this._hasTempPort !== null) {
        const data = this._hasTempPort.data;
        tempid = "abcdefghijklmnopqrstuvwxyz"[data.ports.length-1];
        const port = realnode.findPort(tempid);
        if (port !== null) {
          tempnode.location = port.getDocumentPoint(go.Spot.Center);
        }
      }
    }, null);
    super.copyPortProperties(realnode, realport, tempnode, tempport, toend);
  }

  setNoTargetPortProperties(tempnode, tempport, toend) {
    const augmentedNode = this._hasTempPort;
    if (augmentedNode !== null) {
      this._hasTempPort = null;
        const data = augmentedNode.data;
        if (data && data.ports.length > 0) {
          this.diagram.model.commit(m => {
            m.removeArrayItem(data.ports, data.ports.length-1);
          }, null);
        }
    }
    super.setNoTargetPortProperties(tempnode, tempport, toend);
  }

  cleanupAugmentedNode() {
    const augmentedNode = this._hasTempPort;
    if (augmentedNode !== null) {
      this._hasTempPort = null;
      const data = augmentedNode.data;
      if (data && data.ports.length > 0) {
        this.diagram.model.commit(m => {
          m.removeArrayItem(data.ports, data.ports.length-1);
        }, null);
      }
    }
  }

  insertLink(fromnode, fromport, tonode, toport) {
    this.cleanupAugmentedNode();
    if (tonode && tonode.data) {
      const data = tonode.data;
      const tempid = "abcdefghijklmnopqrstuvwxyz"[data.ports.length];
      if (toport.portId === tempid) {
        this.diagram.model.addArrayItem(data.ports, tempid);
      }
    }
    return super.insertLink(fromnode, fromport, tonode, toport);
  }

  doNoLink(fromnode, fromport, tonode, toport) {
    this.cleanupAugmentedNode();
    super.doNoLink(fromnode, fromport, tonode, toport);
  }
}

const myDiagram =
  new go.Diagram("myDiagramDiv", {
      layout: new go.LayeredDigraphLayout(),
      linkingTool: new CustomLinkingTool(),
      "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 =
  new go.Node("Table", {
      layoutConditions: go.LayoutConditions.Standard & ~go.LayoutConditions.NodeSized
    })
    .add(
      new go.Panel("Auto", { column: 1, stretch: go.Stretch.Vertical, minSize: new go.Size(50, 50) })
        .add(
          new go.Shape({ fill: "white" })
            .bind("fill", "color"),
          new go.TextBlock({ margin: 8 })
            .bind("text")
        ),
      new go.Panel("Vertical", {
          column: 0,
          itemTemplate:
            new go.Panel({
                margin: new go.Margin(1, 0),
                toLinkable: true, 
                toSpot: go.Spot.Left, 
                toMaxLinks: 1
              })
              .bind("portId", "")
              .add(
                new go.Shape({ width: 8, height: 8, fill: "gray" })
              )
        })
        .bind("itemArray", "ports"),
      new go.Shape({
          column: 2, width: 8, height: 8, fill: "gray",
          portId: "out", fromLinkable: true, fromSpot: go.Spot.Right, cursor: "pointer"

        })
    );

myDiagram.linkTemplate =
  new go.Link({
      fromPortId: "out", corner: 6,
      relinkableFrom: true, relinkableTo: true
    })
    .add(new go.Shape());

myDiagram.model = new go.GraphLinksModel({
  linkToPortIdProperty: "tid",
  nodeDataArray:
    [
      { key: 1, text: "Alpha", color: "lightblue", ports: [] },
      { key: 2, text: "Gamma", color: "lightgreen", ports: ["a"] }
    ],
  linkDataArray:
    [
      { from: 1, to: 2, tid: "a" }
    ]
});

document.getElementById("myTestButton").addEventListener("click", e => {
  
});
  </script>
</body>
</html>

There is a link that exists between Alpha and Gamma already. I want to create a new link between those two nodes, with the hopes that a new port will appear on Gamma to allow this behavior to happen. The code sample above does not allow that to happen. The end goal is to have two ports on Gamma with two links from the same Alpha port

OK, here’s a sample that I think meets your needs but does not use a custom LinkingTool.

It uses a default port on each Node to allow a Link to always be drawn by the standard LinkingTool to the node, but the “LinkDrawn” DiagramEvent listener notices that the link is connecting to that default port (portId === “”) and reconnects it to a new named port.

There is also a “LinkRelinked” DiagramEvent listener to handle the case when the user reconnects an existing Link. The same function can be used for both events.

If the user disconnects or deletes a link, the to-port remains and can be linked to in the future, but toMaxLinks is 1 so at most one link can come into each port. Either you’ll need to have a different mechanism to remove ports from the model, or such ports should be removed automatically. Obviously if you look at the model there are input ports that have no links connecting to them, so if you wanted a policy of not having any input ports without links connecting to them, you will also need to have a cleanup procedure to make loaded models “valid”.

The presence of a real default port on each node might allow for a link to connect to that default port, but I assume the link data in the model will always have a “tid” to-port identifier that is not null and not an empty string.

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">

function makePortData(node) {
  let id = '';
  const def = node.port;
  for (id of "abcdefghijklmnopqrstuvwxyz") {
    if (node.findPort(id) === def) break;
  }
  return id;  // or some Object containing id
}

function linkConnected(e) {
  const link = e.subject;
  const node = link.toNode;
  if (node && link.toPortId === "") {
    let ports = node.data.ports;
    if (!Array.isArray(ports)) {
      ports = [];
      e.diagram.model.set(node.data, "ports", ports);
    }
    const newport = makePortData(node);
    e.diagram.model.addArrayItem(ports, newport);
    link.toPortId = newport;  // assuming port data is not an Object but just a string identifier
  }
}

const myDiagram =
  new go.Diagram("myDiagramDiv", {
      layout: new go.LayeredDigraphLayout(),
      "LinkDrawn": linkConnected,
      "LinkRelinked": linkConnected,
      "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 =
  new go.Node("Table", {
      layoutConditions: go.LayoutConditions.Standard & ~go.LayoutConditions.NodeSized
    })
    .add(
      new go.Panel("Auto", {
          column: 1,
          stretch: go.Stretch.Vertical,
          minSize: new go.Size(50, 50)
        })
        .add(
          new go.Shape({
              fill: "white",
              portId: "",
              toLinkable: true,
              toSpot: go.Spot.BottomLeft
            })
            .bind("fill", "color"),
          new go.TextBlock({ margin: 8 })
            .bind("text")
        ),
      new go.Panel("Vertical", {
          column: 0,
          itemTemplate:
            new go.Panel({
                margin: new go.Margin(1, 0),
                toLinkable: true,
                toSpot: go.Spot.Left,
                toMaxLinks: 1
              })
              .bind("portId", "")
              .add(
                new go.Shape({ width: 8, height: 8, fill: "gray" })
              )
        })
        .bind("itemArray", "ports"),
      new go.Shape({
          column: 2, width: 8, height: 8, fill: "gray",
          portId: "out", fromLinkable: true, fromSpot: go.Spot.Right, cursor: "pointer"
        })
    );

myDiagram.linkTemplate =
  new go.Link({
      fromPortId: "out", corner: 6,
      relinkableFrom: true, relinkableTo: true
    })
    .add(new go.Shape());

myDiagram.model = new go.GraphLinksModel({
  linkToPortIdProperty: "tid",
  nodeDataArray:
    [
      { key: 1, text: "Alpha", color: "lightblue", ports: ["a", "b", "c"] },
      { key: 2, text: "Beta", color: "orange", ports: ["a", "b", "c"] },
      { key: 3, text: "Gamma", color: "lightgreen", ports: ["a"] },
      { key: 4, text: "Delta", color: "pink", ports: ["a", "b", "c", "d"] }
    ],
  linkDataArray:
    [
      { from: 1, to: 2, tid: "a" },
      { from: 3, to: 4, tid: "c" }
    ]
});
  </script>
</body>
</html>

Thanks for the code snippet. We have tried a similar approach to this, but modifying the templates to make this behavior work was a bit complex. I’ve attached a gif of the behavior we’re after (although targeting the ports rather than the node itself to have the port appear would be preferred).
PortBehavior

We want the newly created port to act exactly like the other ports, even to the point of the other ports needing their locations recalculated once that hidden port is targeted. By creating a default port that’s hidden, I believe it would need to be a part of the list of ports in the itemTemplate and hidden initially until a connection is drawn to it; that said, I’m not exactly sure how to position that port while hiding it to only be targeted when all other ports are connected. There are also other types of nodes that look the exact same, but should not allow ports to be automatically created past a certain number of ports, so we need a place to hook in prior to the port appearing to not allow this behavior.

All of this was working 80% of the way with our first implementation, including the port expansion. The only caveat was the isValid function wouldn’t allow multiple connections between the same nodes (which makes sense given toMaxLinks was 1). If you have further suggestions to this second approach with modifying the templates to allow for an optionally hidden port, they would be gladly appreciated.