[EDIT: improved]
This doesn’t have support for adding or removing segments (i.e. individual Bezier curves), but I think it has some of the things you are looking for:
<!DOCTYPE html>
<html>
<head>
<title>Double Bezier Link</title>
<!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
<meta name="description" content="a custom routed Link that always has two Bezier curve segments in it">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>
<button id="loadModel" onclick="load()">Load</button>
<button id="saveModel" onclick="save()">Save</button>
<textarea id="mySavedModel" style="width:100%;height:200px">
{ "class": "GraphLinksModel",
"nodeDataArray": [
{"key":1,"text":"Alpha","color":"lightgreen","loc":"0 0"},
{"key":2,"text":"Beta","color":"yellow","loc":"-100 189"},
{"key":3,"text":"Gamma","color":"lightgreen","loc":"100 0"},
{"key":4,"text":"Delta","color":"yellow","loc":"100 189"},
{"key":5,"text":"Epsilon","color":"lightgreen","loc":"200 0"},
{"key":6,"text":"Zeta","color":"yellow","loc":"300 189"}
],
"linkDataArray": [
{"from":1,"to":2,"points":[58.28947448730469,19.547368192672728,87.29297828674316,48.6140338420868,20.824562072753906,83.3649121284485,-2.9078941345214844,109.94035076300304,-26.640350341796875,136.51578939755757,-74.10526275634766,179,-74.10526275634766,189],"adjusting":17},
{"from":3,"to":4,"points":[171.2894744873047,19.547368192672728,181.2894744873047,19.547368192672728,214.59299659729004,82.81403079032899,158.22629737854004,97.38071169853211,82.85965919494629,114.48071780204774,79.69297218322754,154.3807116985321,127.69736862182617,189],"adjusting":17},
{"from":5,"to":6,"points":[267.68421173095703,19.547368192672728,277.68421173095703,19.547368192672728,293.6315797170003,83.3649121284485,301.605263710022,109.94035076300304,309.57894770304364,136.51578939755757,325.5263156890869,179,325.5263156890869,189]}
]}
</textarea>
<script src="../latest/release/go.js"></script>
<script id="code">
// A Link whose route includes two Bezier curve segments (7 points).
// At the current time there is no support for Link.resegmentable
class DoubleBezierLink extends go.Link {
constructor(init) {
super();
this.curve = go.Curve.Bezier;
if (init) Object.assign(this, init);
}
computeEndSegmentLength(node, port, spot, from) {
let result = super.computeEndSegmentLength(node, port, spot, from);
if (this.curve === go.Curve.Bezier && !this.isOrthogonal && !spot.equals(go.Spot.None)) {
result += 20;
}
return result;
}
computePoints() {
const result = super.computePoints(); // do normal routing
const fromport = this.fromPort;
const toport = this.toPort;
if (result && this.curve === go.Curve.Bezier && !this.isOrthogonal && fromport && toport && fromport !== toport && this.pointsCount === 4) {
const fromspot = this.computeSpot(true, fromport); // link's Spot takes precedence, if defined
const tospot = this.computeSpot(false, toport); // link's Spot takes precedence, if defined
if (!fromspot.equals(go.Spot.None) && !tospot.equals(go.Spot.None)) {
const p0 = this.getPoint(0); // the normal end points,
const p1 = this.getPoint(3); // determined by getLinkPoint
const c0 = this.getPoint(1); // the normal control points,
const c1 = this.getPoint(2); // determined by getLinkDirection & computeEndSegmentLength
const fromdir = this.getLinkDirection(fromport.part, fromport, p0, fromspot, true, false, toport.part, toport);
const todir = this.getLinkDirection(toport.part, toport, p1, tospot, false, false, fromport.part, fromport);
const horiz = (fromdir === 0 && todir === 180) || (fromdir === 180 && todir === 0);
const vert = (fromdir === 90 && todir === 270) || (fromdir === 270 && todir === 90);
const doublesback =
(horiz && ((fromdir === 0 && c0.x > c1.x) || (fromdir === 180 && c0.x < c1.x))) ||
(vert && ((fromdir === 90 && c0.y > c1.y) || (fromdir === 270 && c0.y < c1.y)));
let mx = (c0.x + c1.x) / 2; // midpoint
let my = (c0.y + c1.y) / 2;
const curvi = this.computeCurviness();
if (horiz) {
if (doublesback) my += curvi; else mx += curvi;
} else {
if (doublesback) mx += curvi; else my += curvi;
}
const c2x = (mx * 2 + c0.x) / 3;
const c2y = (my * 2 + c0.y) / 3;
const c3x = (mx * 2 + c1.x) / 3;
const c3y = (my * 2 + c1.y) / 3;
this.insertPointAt(2, c2x, c2y);
this.insertPointAt(3, (c2x + c3x) / 2, (c2y + c3y) / 2); // make sure the three points are in a line
this.insertPointAt(4, c3x, c3y);
}
}
return result;
}
// When moving the middle point that is common between both Bezier curves,
// also move the adjacent points so as to maintain the smoothness of the joint at that middle point.
reshape(newPoint) {
const link = this.adornedLink;
if (link === null) return;
const hnd = this.handle;
if (hnd === null) return;
const index = hnd.segmentIndex;
if (index !== 0 && index !== this.PointsCount-1 && index % 3 === 0) {
const prevPoint = link.getPoint(index);
super.reshape(newPoint);
link.startRoute();
link.setPoint(index-1, link.getPoint(index-1).copy().add(newPoint).subtract(prevPoint));
link.setPoint(index+1, link.getPoint(index+1).copy().add(newPoint).subtract(prevPoint));
link.commitRoute();
} else {
super.reshape(newPoint);
}
}
} // end of DoubleBezierLink
class BezierLinkReshapingTool extends go.LinkReshapingTool {
constructor(init) {
super();
if (init) Object.assign(this, init);
}
makeAdornment(pathshape) {
const adornment = super.makeAdornment(pathshape);
if (adornment === null) return null;
const link = pathshape.part;
if (link instanceof go.Link && link.isSelected &&
link.curve === go.Curve.Bezier && !link.isOrthogonal && link.pointsCount > 4) {
const h1 = this.makeHandle(pathshape, 1);
// needs to be a GraphObject so we can set its Cursor
if (h1 !== null) {
// identify this particular handle within the LinkPanel
h1.segmentIndex = 1;
this.setReshapingBehavior(h1, go.ReshapingBehavior.All);
h1.cursor = "move";
adornment.add(h1);
}
const hn = this.makeHandle(pathshape, link.pointsCount - 2);
// needs to be a GraphObject so we can set its Cursor
if (hn !== null) {
// identify this particular handle within the LinkPanel
hn.segmentIndex = link.pointsCount - 2;
this.setReshapingBehavior(hn, go.ReshapingBehavior.All);
hn.cursor = "move";
adornment.add(hn);
}
}
return adornment;
}
updateAdornments(link) {
if (link === null || !(link instanceof go.Link)) return; // tool only applies to Links
let adornment = null;
if (link.isSelected && !this.diagram.isReadOnly) {
const pathshape = link.path;
if (pathshape !== null && link.canReshape() && link.actualBounds.isReal() && link.isVisible() && pathshape.actualBounds.isReal() && pathshape.isVisibleObject()) {
adornment = link.findAdornment(this.name);
if (adornment === null || adornment._oldPointsCount !== link.pointsCount || adornment._wasResegmentable !== link.resegmentable) {
adornment = this.makeAdornment(pathshape);
if (adornment !== null) {
adornment._oldPointsCount = link.pointsCount;
adornment._wasResegmentable = link.resegmentable;
link.addAdornment(this.name, adornment);
}
}
}
}
if (adornment === null) link.removeAdornment(this.name);
}
} // end of CurvedLinkReshapingTool
myDiagram =
new go.Diagram("myDiagramDiv", {
linkReshapingTool: new BezierLinkReshapingTool(),
// override Diagram.copyParts to prevent routing temporary Links by DraggingTool calls to moveParts
copyParts: function(coll, diagram, check) {
const map = go.Diagram.prototype.copyParts.call(this, coll, diagram, check);
if (this.currentTool instanceof go.DraggingTool) {
map.iteratorValues.each(part => {
if (part instanceof go.Link) part.suspendsRouting = true;
});
}
return map;
},
"LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End,
"undoManager.isEnabled": true
});
myDiagram.nodeTemplate =
new go.Node("Auto")
.bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
.add(
new go.Shape({
fill: "white", portId: "",
fromLinkableDuplicates: true, toLinkableDuplicates: true,
fromSpot: go.Spot.Right, toSpot: go.Spot.Top
})
.bind("fill", "color"),
new go.TextBlock({ margin: 12 })
.bind("text")
);
myDiagram.linkTemplate =
new DoubleBezierLink({
reshapable: true // but there's no support for resegmenting
})
.bindTwoWay("adjusting")
.bindTwoWay("points")
.add(
new go.Shape({ strokeWidth: 2 }),
new go.Shape({ toArrow: "OpenTriangle" })
);
myDiagram.model = new go.GraphLinksModel({
nodeDataArray: [
{ "key": 1, "text": "Alpha", "color": "lightgreen", "loc": "0 0" },
{ "key": 2, "text": "Beta", "color": "yellow", "loc": "-100 189" },
{ "key": 3, "text": "Gamma", "color": "lightgreen", "loc": "100 0" },
{ "key": 4, "text": "Delta", "color": "yellow", "loc": "100 189" },
{ "key": 5, "text": "Epsilon", "color": "lightgreen", "loc": "200 0" },
{ "key": 6, "text": "Zeta", "color": "yellow", "loc": "300 189" },
],
linkDataArray: [
{ from: 1, to: 2 },
{ from: 3, to: 4 },
{ from: 5, to: 6 },
]
});
myDiagram.selectCollection(myDiagram.links);
// save a model to and load a model from Json text, displayed below the Diagram
function save() {
const str = myDiagram.model.toJson();
document.getElementById("mySavedModel").value = str;
}
function load() {
const str = document.getElementById("mySavedModel").value;
myDiagram.model = go.Model.fromJson(str);
}
load();
</script>
</body>
</html>