One idea is to have each Node only have one port – the big one with the spots being go.Spot.AllSides.
Then the user can somehow choose whether a Link should always connect at one particular side or on any side. There are various ways you can design that.
In the code below, to let a user control whether it’s a specific side or not at the time that the user is drawing a new Link, the user can hold down the Ctrl modifier, or the mouse point can be in the port (but not any other element).
But an alternative way to specify that could be to ask the user which side it should be, or no specific side. Or you could let them edit it, for either or both ends of an existing Link. It’s your choice for how you want to let the user decide.
Try this code:
<!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>
Each Node has only one port, with spots that are AllSides.
Each Link has a Binding on the <b>Link.toSpot</b> property.
Note how the link from Delta to Alpha always is on the bottom side of the toNode,
regardless of where Delta is relative to Alpha.
<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">
const myDiagram =
new go.Diagram("myDiagramDiv", {
"LinkDrawn": e => {
const link = e.subject;
const p = e.diagram.lastInput.documentPoint;
// When the Ctrl modifier is held down at the time the link is connected,
// or when the mouse is over the port and not any other element like the TextBlock,
// decide which side is closest to the mouse point that the link should connect at that.
if (link.toPort !== null && (e.diagram.lastInput.control || e.diagram.findObjectAt(p) == link.toPort)) {
const dir = nearestSideDirection(link.toPort.getDocumentBounds(), p);
link.toSpot = dir === 0 ? go.Spot.RightSide : (dir === 90 ? go.Spot.BottomSide : (dir === 180 ? go.Spot.LeftSide : go.Spot.TopSide));
}
},
"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();
}
}
});
function nearestSideDirection(r, p) { // this will be a method on Rect in v3.1
let res = 0;
const spotx = r === null ? p.x : ((p.x - r.x) / (r.width > 0 ? r.width : 1));
const spoty = r === null ? p.y : ((p.y - r.y) / (r.height > 0 ? r.height : 1));
if (spotx > spoty) {
if (spotx > 1 - spoty) {
// res = 0;
} else if (spotx < 1 - spoty) {
res = 270;
} else {
res = 315;
}
} else if (spotx < spoty) {
if (spotx > 1 - spoty) {
res = 90;
} else if (spotx < 1 - spoty) {
res = 180;
} else {
res = 135;
}
} else {
if (spotx < 0.5) {
res = 225;
} else if (spotx > 0.5) {
res = 45;
// } else {
// res = 0;
}
}
return res;
}
myDiagram.nodeTemplate =
new go.Node("Auto")
.bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
.add(
new go.Shape({
fill: "white", portId: "", cursor: "pointer",
fromLinkable: true, fromSpot: go.Spot.AllSides,
toLinkable: true, toSpot: go.Spot.AllSides
})
.bind("fill", "color"),
new go.TextBlock({ margin: 8 })
.bind("text")
);
myDiagram.linkTemplate =
new go.Link({ routing: go.Link.Orthogonal, corner: 10 })
.bindTwoWay("toSpot", "toSpot", go.Spot.parse, go.Spot.stringify)
.add(
new go.Shape(),
new go.Shape({ toArrow: "OpenTriangle" })
);
myDiagram.model = new go.GraphLinksModel(
[
{ key: 1, text: "Alpha", color: "lightblue" },
{ key: 2, text: "Beta", color: "orange" },
{ key: 3, text: "Gamma", color: "lightgreen" },
{ key: 4, text: "Delta", color: "pink" }
],
[
{ from: 1, to: 2 },
{ from: 1, to: 3 },
{ from: 3, to: 4 },
{ from: 4, to: 1, toSpot: "BottomSide" }
]);
</script>
</body>
</html>