OK, try this unsupported and inefficient solution. I have only changed the sample to show a new model that is like what you show in your screenshot, and to make use of some additional code that tries to reroute orthogonal links to avoid overlapping link segments. That code does not try to make sure any new routes do not overlap other nodes.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dynamic Ports</title>
<meta name="description" content="Nodes with varying lists of ports on each of four sides." />
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Copyright 1998-2020 by Northwoods Software Corporation. -->
<script src="go.js"></script>
<script src="AvoidsLinksRouter.js"></script>
<script id="code">
function init() {
var $ = go.GraphObject.make; //for conciseness in defining node templates
myDiagram =
$(go.Diagram, "myDiagramDiv", //Diagram refers to its DIV HTML element by id
{
"LayoutCompleted": function(e) { myRouter.avoidOrthogonalOverlaps(e.diagram.links); },
"SelectionMoved": function(e) { myRouter.avoidOrthogonalOverlaps(e.diagram.links); },
"undoManager.isEnabled": true
});
var myRouter = new AvoidsLinksRouter();
// when the document is modified, add a "*" to the title and enable the "Save" button
myDiagram.addDiagramListener("Modified", function(e) {
var button = document.getElementById("SaveButton");
if (button) button.disabled = !myDiagram.isModified;
var idx = document.title.indexOf("*");
if (myDiagram.isModified) {
if (idx < 0) document.title += "*";
} else {
if (idx >= 0) document.title = document.title.substr(0, idx);
}
});
// To simplify this code we define a function for creating a context menu button:
function makeButton(text, action, visiblePredicate) {
return $("ContextMenuButton",
$(go.TextBlock, text),
{ click: action },
// don't bother with binding GraphObject.visible if there's no predicate
visiblePredicate ? new go.Binding("visible", "", function(o, e) { return o.diagram ? visiblePredicate(o, e) : false; }).ofObject() : {});
}
var nodeMenu = // context menu for each Node
$("ContextMenu",
makeButton("Copy",
function(e, obj) { e.diagram.commandHandler.copySelection(); }),
makeButton("Delete",
function(e, obj) { e.diagram.commandHandler.deleteSelection(); }),
$(go.Shape, "LineH", { strokeWidth: 2, height: 1, stretch: go.GraphObject.Horizontal }),
makeButton("Add top port",
function(e, obj) { addPort("top"); }),
makeButton("Add left port",
function(e, obj) { addPort("left"); }),
makeButton("Add right port",
function(e, obj) { addPort("right"); }),
makeButton("Add bottom port",
function(e, obj) { addPort("bottom"); })
);
var portSize = new go.Size(8, 8);
var portMenu = // context menu for each port
$("ContextMenu",
makeButton("Swap order",
function(e, obj) { swapOrder(obj.part.adornedObject); }),
makeButton("Remove port",
// in the click event handler, the obj.part is the Adornment;
// its adornedObject is the port
function(e, obj) { removePort(obj.part.adornedObject); }),
makeButton("Change color",
function(e, obj) { changeColor(obj.part.adornedObject); }),
makeButton("Remove side ports",
function(e, obj) { removeAll(obj.part.adornedObject); })
);
// the node template
// includes a panel on each side with an itemArray of panels containing ports
myDiagram.nodeTemplate =
$(go.Node, "Table",
{
locationObjectName: "BODY",
locationSpot: go.Spot.Center,
selectionObjectName: "BODY",
contextMenu: nodeMenu
},
new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
// the body
$(go.Panel, "Auto",
{
row: 1, column: 1, name: "BODY",
stretch: go.GraphObject.Fill
},
$(go.Shape, "Rectangle",
{
fill: "#dbf6cb", stroke: null, strokeWidth: 0,
minSize: new go.Size(60, 60)
}),
$(go.TextBlock,
{ margin: 10, textAlign: "center", font: "bold 14px Segoe UI,sans-serif", stroke: "#484848", editable: true },
new go.Binding("text", "name").makeTwoWay())
), // end Auto Panel body
// the Panel holding the left port elements, which are themselves Panels,
// created for each item in the itemArray, bound to data.leftArray
$(go.Panel, "Vertical",
new go.Binding("itemArray", "leftArray"),
{
row: 1, column: 0,
itemTemplate:
$(go.Panel,
{
_side: "left", // internal property to make it easier to tell which side it's on
fromSpot: go.Spot.Left, toSpot: go.Spot.Left,
fromLinkable: true, toLinkable: true, cursor: "pointer",
contextMenu: portMenu
},
new go.Binding("portId", "portId"),
$(go.Shape, "Rectangle",
{
stroke: null, strokeWidth: 0,
desiredSize: portSize,
margin: new go.Margin(1, 0)
},
new go.Binding("fill", "portColor"))
) // end itemTemplate
}
), // end Vertical Panel
// the Panel holding the top port elements, which are themselves Panels,
// created for each item in the itemArray, bound to data.topArray
$(go.Panel, "Horizontal",
new go.Binding("itemArray", "topArray"),
{
row: 0, column: 1,
itemTemplate:
$(go.Panel,
{
_side: "top",
fromSpot: go.Spot.Top, toSpot: go.Spot.Top,
fromLinkable: true, toLinkable: true, cursor: "pointer",
contextMenu: portMenu
},
new go.Binding("portId", "portId"),
$(go.Shape, "Rectangle",
{
stroke: null, strokeWidth: 0,
desiredSize: portSize,
margin: new go.Margin(0, 1)
},
new go.Binding("fill", "portColor"))
) // end itemTemplate
}
), // end Horizontal Panel
// the Panel holding the right port elements, which are themselves Panels,
// created for each item in the itemArray, bound to data.rightArray
$(go.Panel, "Vertical",
new go.Binding("itemArray", "rightArray"),
{
row: 1, column: 2,
itemTemplate:
$(go.Panel,
{
_side: "right",
fromSpot: go.Spot.Right, toSpot: go.Spot.Right,
fromLinkable: true, toLinkable: true, cursor: "pointer",
contextMenu: portMenu
},
new go.Binding("portId", "portId"),
$(go.Shape, "Rectangle",
{
stroke: null, strokeWidth: 0,
desiredSize: portSize,
margin: new go.Margin(1, 0)
},
new go.Binding("fill", "portColor"))
) // end itemTemplate
}
), // end Vertical Panel
// the Panel holding the bottom port elements, which are themselves Panels,
// created for each item in the itemArray, bound to data.bottomArray
$(go.Panel, "Horizontal",
new go.Binding("itemArray", "bottomArray"),
{
row: 2, column: 1,
itemTemplate:
$(go.Panel,
{
_side: "bottom",
fromSpot: go.Spot.Bottom, toSpot: go.Spot.Bottom,
fromLinkable: true, toLinkable: true, cursor: "pointer",
contextMenu: portMenu
},
new go.Binding("portId", "portId"),
$(go.Shape, "Rectangle",
{
stroke: null, strokeWidth: 0,
desiredSize: portSize,
margin: new go.Margin(0, 1)
},
new go.Binding("fill", "portColor"))
) // end itemTemplate
}
) // end Horizontal Panel
); // end Node
// an orthogonal link template, reshapable and relinkable
myDiagram.linkTemplate =
$(CustomLink, // defined below
{
routing: go.Link.AvoidsNodes,
corner: 4,
curve: go.Link.JumpGap,
reshapable: true,
resegmentable: true,
relinkableFrom: true,
relinkableTo: true
},
new go.Binding("points").makeTwoWay(),
$(go.Shape, { stroke: "#2F4F4F", strokeWidth: 2 })
);
// support double-clicking in the background to add a copy of this data as a node
myDiagram.toolManager.clickCreatingTool.archetypeNodeData = {
name: "Unit",
leftArray: [],
rightArray: [],
topArray: [],
bottomArray: []
};
myDiagram.contextMenu =
$("ContextMenu",
makeButton("Paste",
function(e, obj) { e.diagram.commandHandler.pasteSelection(e.diagram.toolManager.contextMenuTool.mouseDownPoint); },
function(o) { return o.diagram.commandHandler.canPasteSelection(o.diagram.toolManager.contextMenuTool.mouseDownPoint); }),
makeButton("Undo",
function(e, obj) { e.diagram.commandHandler.undo(); },
function(o) { return o.diagram.commandHandler.canUndo(); }),
makeButton("Redo",
function(e, obj) { e.diagram.commandHandler.redo(); },
function(o) { return o.diagram.commandHandler.canRedo(); })
);
// load the diagram from JSON data
load();
}
// This custom-routing Link class tries to separate parallel links from each other.
// This assumes that ports are lined up in a row/column on a side of the node.
function CustomLink() {
go.Link.call(this);
};
go.Diagram.inherit(CustomLink, go.Link);
CustomLink.prototype.findSidePortIndexAndCount = function(node, port) {
var nodedata = node.data;
if (nodedata !== null) {
var portdata = port.data;
var side = port._side;
var arr = nodedata[side + "Array"];
var len = arr.length;
for (var i = 0; i < len; i++) {
if (arr[i] === portdata) return [i, len];
}
}
return [-1, len];
};
CustomLink.prototype.computeEndSegmentLength = function(node, port, spot, from) {
var esl = go.Link.prototype.computeEndSegmentLength.call(this, node, port, spot, from);
var other = this.getOtherPort(port);
if (port !== null && other !== null) {
var thispt = port.getDocumentPoint(this.computeSpot(from));
var otherpt = other.getDocumentPoint(this.computeSpot(!from));
if (Math.abs(thispt.x - otherpt.x) > 20 || Math.abs(thispt.y - otherpt.y) > 20) {
var info = this.findSidePortIndexAndCount(node, port);
var idx = info[0];
var count = info[1];
if (port._side == "top" || port._side == "bottom") {
if (otherpt.x < thispt.x) {
return esl + 4 + idx * 8;
} else {
return esl + (count - idx - 1) * 8;
}
} else { // left or right
if (otherpt.y < thispt.y) {
return esl + 4 + idx * 8;
} else {
return esl + (count - idx - 1) * 8;
}
}
}
}
return esl;
};
CustomLink.prototype.hasCurviness = function() {
if (isNaN(this.curviness)) return true;
return go.Link.prototype.hasCurviness.call(this);
};
CustomLink.prototype.computeCurviness = function() {
if (isNaN(this.curviness)) {
var fromnode = this.fromNode;
var fromport = this.fromPort;
var fromspot = this.computeSpot(true);
var frompt = fromport.getDocumentPoint(fromspot);
var tonode = this.toNode;
var toport = this.toPort;
var tospot = this.computeSpot(false);
var topt = toport.getDocumentPoint(tospot);
if (Math.abs(frompt.x - topt.x) > 20 || Math.abs(frompt.y - topt.y) > 20) {
if ((fromspot.equals(go.Spot.Left) || fromspot.equals(go.Spot.Right)) &&
(tospot.equals(go.Spot.Left) || tospot.equals(go.Spot.Right))) {
var fromseglen = this.computeEndSegmentLength(fromnode, fromport, fromspot, true);
var toseglen = this.computeEndSegmentLength(tonode, toport, tospot, false);
var c = (fromseglen - toseglen) / 2;
if (frompt.x + fromseglen >= topt.x - toseglen) {
if (frompt.y < topt.y) return c;
if (frompt.y > topt.y) return -c;
}
} else if ((fromspot.equals(go.Spot.Top) || fromspot.equals(go.Spot.Bottom)) &&
(tospot.equals(go.Spot.Top) || tospot.equals(go.Spot.Bottom))) {
var fromseglen = this.computeEndSegmentLength(fromnode, fromport, fromspot, true);
var toseglen = this.computeEndSegmentLength(tonode, toport, tospot, false);
var c = (fromseglen - toseglen) / 2;
if (frompt.x + fromseglen >= topt.x - toseglen) {
if (frompt.y < topt.y) return c;
if (frompt.y > topt.y) return -c;
}
}
}
}
return go.Link.prototype.computeCurviness.call(this);
};
// end CustomLink class
// Add a port to the specified side of the selected nodes.
function addPort(side) {
myDiagram.startTransaction("addPort");
myDiagram.selection.each(function(node) {
// skip any selected Links
if (!(node instanceof go.Node)) return;
// compute the next available index number for the side
var i = 0;
while (node.findPort(side + i.toString()) !== node) i++;
// now this new port name is unique within the whole Node because of the side prefix
var name = side + i.toString();
// get the Array of port data to be modified
var arr = node.data[side + "Array"];
if (arr) {
// create a new port data object
var newportdata = {
portId: name,
portColor: getPortColor()
// if you add port data properties here, you should copy them in copyPortData above
};
// and add it to the Array of port data
myDiagram.model.insertArrayItem(arr, -1, newportdata);
}
});
myDiagram.commitTransaction("addPort");
}
// Exchange the position/order of the given port with the next one.
// If it's the last one, swap with the previous one.
function swapOrder(port) {
var arr = port.panel.itemArray;
if (arr.length >= 2) { // only if there are at least two ports!
for (var i = 0; i < arr.length; i++) {
if (arr[i].portId === port.portId) {
myDiagram.startTransaction("swap ports");
if (i >= arr.length - 1) i--; // now can swap I and I+1, even if it's the last port
var newarr = arr.slice(0); // copy Array
newarr[i] = arr[i + 1]; // swap items
newarr[i + 1] = arr[i];
// remember the new Array in the model
myDiagram.model.setDataProperty(port.part.data, port._side + "Array", newarr);
myDiagram.commitTransaction("swap ports");
break;
}
}
}
}
// Remove the clicked port from the node.
// Links to the port will be redrawn to the node's shape.
function removePort(port) {
myDiagram.startTransaction("removePort");
var pid = port.portId;
var arr = port.panel.itemArray;
for (var i = 0; i < arr.length; i++) {
if (arr[i].portId === pid) {
myDiagram.model.removeArrayItem(arr, i);
break;
}
}
myDiagram.commitTransaction("removePort");
}
// Remove all ports from the same side of the node as the clicked port.
function removeAll(port) {
myDiagram.startTransaction("removePorts");
var nodedata = port.part.data;
var side = port._side; // there are four property names, all ending in "Array"
myDiagram.model.setDataProperty(nodedata, side + "Array", []); // an empty Array
myDiagram.commitTransaction("removePorts");
}
// Change the color of the clicked port.
function changeColor(port) {
myDiagram.startTransaction("colorPort");
var data = port.data;
myDiagram.model.setDataProperty(data, "portColor", getPortColor());
myDiagram.commitTransaction("colorPort");
}
// Use some pastel colors for ports
function getPortColor() {
var portColors = ["#fae3d7", "#d6effc", "#ebe3fc", "#eaeef8", "#fadfe5", "#6cafdb", "#66d6d1"]
return portColors[Math.floor(Math.random() * portColors.length)];
}
// Save the model to / load it from JSON text shown on the page itself, not in a database.
function save() {
document.getElementById("mySavedModel").value = myDiagram.model.toJson();
myDiagram.isModified = false;
}
function load() {
myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
// When copying a node, we need to copy the data that the node is bound to.
// This JavaScript object includes properties for the node as a whole, and
// four properties that are Arrays holding data for each port.
// Those arrays and port data objects need to be copied too.
// Thus Model.copiesArrays and Model.copiesArrayObjects both need to be true.
// Link data includes the names of the to- and from- ports;
// so the GraphLinksModel needs to set these property names:
// linkFromPortIdProperty and linkToPortIdProperty.
}
</script>
</head>
<body onload="init()">
<div id="sample">
<div id="myDiagramDiv" style="width:600px; height:500px; border:1px solid black"></div>
Add port to selected nodes:
<button onclick="addPort('top')">Top</button>
<button onclick="addPort('bottom')">Bottom</button>
<button onclick="addPort('left')">Left</button>
<button onclick="addPort('right')">Right</button>
<div>
<div>
<button id="SaveButton" onclick="save()">Save</button>
<button onclick="load()">Load</button>
Diagram Model saved in JSON format:
</div>
<textarea id="mySavedModel" style="width:100%;height:250px">
{ "class": "go.GraphLinksModel",
"copiesArrays": true,
"copiesArrayObjects": true,
"linkFromPortIdProperty": "fromPort",
"linkToPortIdProperty": "toPort",
"nodeDataArray": [
{"key":1, "name":"Unit\nOne", "loc":"100 150",
"leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
"topArray":[],
"bottomArray":[],
"rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] },
{"key":2, "name":"Unit\nTwo", "loc":"100 300",
"leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
"topArray":[],
"bottomArray":[],
"rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] },
{"key":3, "name":"Unit\nThree", "loc":"300 150",
"leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
"topArray":[],
"bottomArray":[],
"rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] },
{"key":4, "name":"Unit\nFour", "loc":"300 300",
"leftArray":[ {"portColor":"#66d6d1", "portId":"left0"}, {"portColor":"#66d6d1", "portId":"left1"}, {"portColor":"#66d6d1", "portId":"left2"} ],
"topArray":[],
"bottomArray":[],
"rightArray":[ {"portColor":"#6cafdb", "portId":"right0"}, {"portColor":"#6cafdb", "portId":"right1"}, {"portColor":"#6cafdb", "portId":"right2"} ] }
],
"linkDataArray": [
{"from":1, "to":2, "fromPort":"right0", "toPort":"left0"},
{"from":1, "to":2, "fromPort":"right1", "toPort":"left1"},
{"from":1, "to":2, "fromPort":"right2", "toPort":"left2"},
{"from":2, "to":1, "fromPort":"right0", "toPort":"left0"},
{"from":2, "to":1, "fromPort":"right1", "toPort":"left1"},
{"from":2, "to":1, "fromPort":"right2", "toPort":"left2"},
{"from":3, "to":4, "fromPort":"right0", "toPort":"left2"},
{"from":3, "to":4, "fromPort":"right1", "toPort":"left1"},
{"from":3, "to":4, "fromPort":"right2", "toPort":"left0"},
{"from":4, "to":3, "fromPort":"right0", "toPort":"left2"},
{"from":4, "to":3, "fromPort":"right1", "toPort":"left1"},
{"from":4, "to":3, "fromPort":"right2", "toPort":"left0"}
]}
</textarea>
</div>
</div>
</body>
</html>
Copy the unsupported and inefficient AvoidsLinksRouter.js
file from https://gojs.net/temp/
.