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 src="../out/go-dev.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 if those about-to-be-seen ports? Or calculate where they should be?