Creating Multiple links by dragging at a time from a node

Hi

Is it possible to create a multiple links at a time by dragging from a node

Expected like this

The LinkingTool has a single LinkingTool.temporaryLink that is used as the “proposed new connection” during the operation of the LinkingTool.

It should be possible to extend that tool to support as many temporary links as there are chosen ports. I don’t think we have any examples of that, but I remember doing similar with another “GO” library on a different platform many years ago.

could you please provide a sample code for this issue in gojs

Things are pretty busy, so I may not have time this week.

Hi @walter,
I’d like to achieve a similar result. I store the port selection state in data, and allow multiple ports to be selected. I was able to get it working with the LinkDrawn event, so it creates the correct links after mouse up, but this won’t show any visualization while the mouse is down.

Can you please pinpoint where should I start with extending the LinkingTool?

My code for reference for others:

if (this.state.selectedPortIds.length > 1) {
    const sourceNeighbors = this.findPortNeighbors(newLink.fromPortId).map(n => n._id);
    const selectedSourceNeighbors = this.state.selectedPortIds.filter(p => sourceNeighbors.includes(p));
    const draggedSourcePortIndex = selectedSourceNeighbors.findIndex(p => p === newLink.fromPortId);

    const destinationNeighbors = this.findPortNeighbors(newLink.toPortId).map(n => n._id);
    const draggedDestinationPortIndex = destinationNeighbors.findIndex(p => p === newLink.toPortId);

    // We are trying to find the matching selection on the destination node while
    // the user can drag from any port of their selection. Example: on the source node we have A, B, C, D, and E ports and
    // on the destination 1, 2, 3, 4, 5. User selects B, C, D and drags C to 3. We want to have the following links created:
    // B -> 2, C -> 3, D -> 4. We found the index of port C **in the selected range** (B, 
    // C, D): index(C)=1. We looked for the index of the destination port (3) **in the full destination neighbor range** (1, 
    // 2, 3, 4, 5): index(3)=2. The matching selection on the destination neighbor range will start at *destinationIndex - 
    // sourceIndex* (2 - 1 = 1, which is equal of index(2)) and will end at *destinationIndex - sourceIndex + length of the 
    // selected source range - 1* (2 - 1 + 3 - 1 = 3, which is equal of index(4)).
    const correspondingDestinationNeighbors = new Array<string>();
    for (
        let i = draggedDestinationPortIndex - draggedSourcePortIndex;
        i < draggedDestinationPortIndex + selectedSourceNeighbors.length - draggedSourcePortIndex;
        i++
    ) {
        correspondingDestinationNeighbors.push(destinationNeighbors[i]);
    }

    for (let i = 0; i < selectedSourceNeighbors.length; i++) {
        let fromPort = this.props.portMap.get(selectedSourceNeighbors[i]);
        let toPort = this.props.portMap.get(correspondingDestinationNeighbors[i]);
        const newId = newGuid();

        // We need to manually add the newly created links to the gojs diagram (except the one the user was dragging)
        if (newLink.fromPortId !== fromPort._id && newLink.toPortId !== toPort._id) {
            e.diagram.startTransaction("Add multiple links");
            let newLinkNeighbor: LinkDiagramModel = {
                diagramModelType: DiagramModelType.Link,
                _id: newId,
                from: newLink.fromNode.key.toString(),
                to: newLink.toNode.key.toString(),
                fromPortId: fromPort._id,
                toPortId: toPort._id,
            };
            (e.diagram.model as go.GraphLinksModel).addLinkData(newLinkNeighbor);
            e.diagram.commitTransaction("Add multiple links");
        }
    }
}

Thanks for the detailed comment in your code.

I think you basically want to override LinkingTool methods doActivate, copyPortProperties, setNoTargetPortProperties, and doDeactivate to do what you need.

The constructor creates the temporaryLink and the temporaryFromNode and temporaryToNode. The standard behavior of doActivate is to set up the two temporary nodes and temporary link and add them to the diagram. Its call to copyPortProperties will make the temporary link route in the manner expected as if it were connecting the actual nodes/ports. doDeactivate of course just removes the temporary link and temporary nodes from the diagram.

But you will need to have a collection of such temporary links and corresponding temporary nodes. You should allocate what you need dynamically in your doActivate, and then remove them all in doDeactivate.

During the user’s drag, there are many calls to doMouseMove. That method looks for a valid port to connect with and then calls either copyPortProperties (if it finds a valid target) or setNoTargetPortProperties (if it doesn’t). So I think you want to override those two methods to do what it normally does but for all of the temporary links and temporary nodes that you will have, instead of the constructor-allocated ones. Here’s what those two methods normally do:

  protected copyPortProperties(realnode: Node | null, realport: GraphObject | null, tempnode: Node, tempport: GraphObject, toend: boolean): void {
    if (realnode === null || realport === null || tempnode === null || tempport === null) return;
    const scale = realport.getDocumentScale();
    const tempsize = new Size();
    tempsize.width = realport.naturalBounds.width * scale;
    tempsize.height = realport.naturalBounds.height * scale;
    tempport.desiredSize = tempsize;
    if (toend) {
      tempport.toSpot = realport.toSpot;
      tempport.toEndSegmentLength = realport.toEndSegmentLength;
    } else {
      tempport.fromSpot = realport.fromSpot;
      tempport.fromEndSegmentLength = realport.fromEndSegmentLength;
    }
    tempnode.locationSpot = Spot.Center;
    const temploc = new Point();
    tempnode.location = realport.getDocumentPoint(Spot.Center, temploc);
    tempport.angle = realport.getDocumentAngle();
    if (this.portTargeted !== null) {
      this.portTargeted(realnode, realport, tempnode, tempport, toend);
    }
  }

  protected setNoTargetPortProperties(tempnode: Node, tempport: GraphObject, toend: boolean): void {
    if (tempport !== null) {
      tempport.desiredSize = new Size(1, 1);
      tempport.fromSpot = Spot.None;
      tempport.toSpot = Spot.None;
    }
    if (tempnode !== null) {
      tempnode.location = this.diagram.lastInput.documentPoint;
    }
    if (this.portTargeted !== null) {
      this.portTargeted(null, null, tempnode, tempport, toend);
    }
  }

I suppose your code could be a bit simpler if you don’t need to worry about any port being scaled or rotated.

Thanks for the answer Walter! I’ll try to work this out. Can you please send me the original implementation of doActivate, doDeactivate and this.portTargeted methods?

I’m thinking about creating a list of targeted ports in doActivate, and updating that in copyPortProperties and setNoTargetPortProperties. Do you think this will work? Who is responsible for addign the temporary nodes and links to the diagram? doActivate and doDeactivate? Are they called every time from doMouseMove as well? Or it it done by this.portTargeted?

Knowing what doActivate and doDeactivate do doesn’t matter to you. You just need to call them to get the standard behavior, and then add whatever setup and cleanup you need for your multiple temporary links.

LinkingBaseTool.portTargeted is null, by default. It is called from copyPortProperties, as shown in the code above.

You might be interested in a very similar situation, with a slightly different link-drawing effect:
https://gojs.net/extras/multiLink.html
The difference is that there is only one temporary link. But multiple links can be drawn, depending on how many consecutive ports are selected on one side of one node. You might have a better way for the user to select multiple ports.

Thanks Walter for the info!

It’s still not entirely clear to me where and how should I create the multiple new temporary links. Where is the single temporary link actually added to the diagram? In doDeactivate?

No, in doActivate. I’ll give you the code, but it might be depending on internal state or methods so that you cannot use it in an override. But you can certainly use it for learning about what it does by default.

  public doActivate(): void {
    const diagram = this.diagram;
    const startPort = this.findLinkablePort();
    if (startPort === null) return;

    this.startTransaction(this.name);

    diagram.isMouseCaptured = true;
    diagram.currentCursor = this.linkingCursor;

    if (this.isForwards) {
      if (this.temporaryToNode !== null && !this.temporaryToNode.location.isReal()) {
        this.temporaryToNode.location = diagram.lastInput.documentPoint;
      }
      this.originalFromPort = startPort;
      const node = this.originalFromPort.part;
      if (node instanceof Node) this.originalFromNode = node;
      this.copyPortProperties(this.originalFromNode, this.originalFromPort, this.temporaryFromNode, this.temporaryFromPort, false);
    } else {
      if (this.temporaryFromNode !== null && !this.temporaryFromNode.location.isReal()) {
        this.temporaryFromNode.location = diagram.lastInput.documentPoint;
      }
      this.originalToPort = startPort;
      const node = this.originalToPort.part;
      if (node instanceof Node) this.originalToNode = node;
      this.copyPortProperties(this.originalToNode, this.originalToPort, this.temporaryToNode, this.temporaryToPort, true);
    }

    diagram.add(this.temporaryFromNode);
    diagram.add(this.temporaryToNode);

    if (this.temporaryLink !== null) {
      if (this.temporaryFromNode !== null) {
        this.temporaryLink.fromNode = this.temporaryFromNode;
      }
      if (this.temporaryToNode !== null) {
        this.temporaryLink.toNode = this.temporaryToNode;
      }
      this.temporaryLink.isTreeLink = this.isNewTreeLink();
      this.temporaryLink.invalidateRoute();
      diagram.add(this.temporaryLink);
    }

    this.isActive = true;
  }
public doDeactivate(): void {
    this.isActive = false;
    const diagram = this.diagram;
    diagram.remove(this.temporaryLink);
    diagram.remove(this.temporaryFromNode);
    diagram.remove(this.temporaryToNode);
    diagram.isMouseCaptured = false;
    diagram.currentCursor = '';
    this.stopTransaction();
  }

I’d like to get the portId of the port from where the user starts dragging in doActivate to save the selected port ids on the source node, but startPort is always null.
const startPort = this.findLinkablePort();

In your sample (MultiLinkingTool and DragSelectingPortsTool) this is solved by only allowing multi-linking from the first selected port, but for me the requirement is to allow it from any selected port.

I suppose you could override findLinkablePort to return any permissible port.

But why should I override it? I mean, isn’t it the same way how normally you get the port where the user starts dragging in doActivate?

You don’t need to override that method. Can’t you use originalFromPort?

Hi Walter!
I’m slow like a snail, but we are progressing! :)
originalFromPort was working, thanks for the tipp!

The next (and hopefully last) issue of mine is that I have to create temporary ports and links after I get the list of ports that should be temporarily linked. But as far as I see this is managed by portTargeted() which is only able to create a single temporary link, but not multiple. How would you solve this?

Here’s my current implementation:

copyPortProperties(realnode: go.Node | null, realport: go.GraphObject | null, tempnode: go.Node, tempport: go.GraphObject, toend: boolean): void {
    if (realnode === null || realport === null || tempnode === null || tempport === null) return;
    if (this.selectedSourceNeighbors !== null && this.draggedSourcePortId !== null) {
        if (realport.portId !== this.lastDraggedDestinationPortId) {
            this.lastDraggedDestinationPortId = realport.portId;
            let cachedNeighbors = this.correspondingDestinationNeighborsCache.get(realport.portId);
            if (!cachedNeighbors) {
                const nodes = this.diagram.model.nodeDataArray as DeviceHeaderDiagramModel[];
                const destinationNeighbors = findPortNeighbors(nodes, realport.portId).map(n => n._id);
                const correspondingDestinationNeighbors = findCorrespondingDestinationNeighbors(
                    this.selectedSourceNeighbors,
                    destinationNeighbors,
                    this.draggedSourcePortId,
                    realport.portId);
                this.correspondingDestinationNeighborsCache.set(realport.portId, correspondingDestinationNeighbors);
                cachedNeighbors = correspondingDestinationNeighbors;
            }
            // I have to create temporary ports and links to the ports with the ids in the cachedNeighbors array here.
        }
    }
    const scale = realport.getDocumentScale();
    const tempsize = new go.Size();
    tempsize.width = realport.naturalBounds.width * scale;
    tempsize.height = realport.naturalBounds.height * scale;
    tempport.desiredSize = tempsize;
    if (toend) {
        tempport.toSpot = realport.toSpot;
        tempport.toEndSegmentLength = realport.toEndSegmentLength;
    } else {
        tempport.fromSpot = realport.fromSpot;
        tempport.fromEndSegmentLength = realport.fromEndSegmentLength;
    }
    tempnode.locationSpot = go.Spot.Center;
    const temploc = new go.Point();
    tempnode.location = realport.getDocumentPoint(go.Spot.Center, temploc);
    tempport.angle = realport.getDocumentAngle();
    if (this.portTargeted !== null) {
        this.portTargeted(realnode, realport, tempnode, tempport, toend);
    }
}

Thanks a bunch!

I would still override copyPortProperties and setNoTargetPortProperties to create the temporary fromNodes/fromPorts, the temporary toNodes/toPorts, and the temporary Links, if needed, and then position the ports appropriately.