jsPlumb Community edition has four connector types available:
Bezier
Straight
Flowchart
StateMachine
and jsPlumb Toolkit edition has all of these plus Orthogonal
, which is an editable version of the Flowchart
connector. Together these connectors cater for quite a few use cases, but if none of these are exactly what you need, it is possible to define your own custom connectors. In this post we'll take a look at how to do that, by defining a connector that provides a line taking the form of a triangle wave between its two endpoints.
The approach detailed here works for version 5.x of both the Community edition and Toolkit edition.
CONNECTOR CONCEPTS
A Connector is basically a path between two points. jsPlumb represents a Connector as a series of segments
, of which there are three
types:
The various Connectors that ship with jsPlumb consist of combinations of these basic segment types. A Straight
connector, for instance, consists of a single Straight segment. Bezier
and StateMachine
connectors consist of a single Bezier segment. A Flowchart connector consists of a series of Straight segments, and if cornerRadius
is set, then each pair of Straight segments has an Arc segment in between.
These three basic segment types have so far been sufficient to define all of the connectors in jsPlumb, and for the triangle wave example I will be modelling the connector as a series of straight segments. But it is feasible that at some stage in the future there will be a need for a segment that models an arbitrary path. If you're reading this and you find that might apply to you, get in touch and we'll see what we can do.
THE MATH
It helps to first sketch up what you're aiming for. Here I'm using an HTML canvas to draw how I want the triangle wave connector to look. Using a Canvas has the obvious advantage that once I get it how I want I've got most of the hard work done! Obviously it also has the disadvantage that if you're looking at this site in IE<9 you won't see anything. That's ok. If you're looking at this page with a view to doing anything about it, then you're a web developer...you have a real browser kicking around somewhere.
The basic approach to creating a triangle wave is to get the equation for the line joining the two endpoints, then create a parallel line above and below this line. These parallel lines are the lines on which the peaks of the wave will sit.
const wavelength = 10, amplitude = 10;
const anchor1 = [ a, b ],
anchor2 = [ c, d ];
const dx = c - a,
dy = d - b,
d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)),
m = dy / dx,
n = -1 / m;
const peaks = Math.round(d / wavelength),
shift = (d - (peaks * wavelength) / 2);
let points = [ anchor1 ], upper = true;
for (let i = 0; i < peaks; i++) {
const xy = pointOnLine(shift + ((i+1) * wavelength)),
pxy = translatePoint(xy, upper);
points.push(pxy);
upper = !upper;
}
points.push(a2);
Here, pointOnLine
and translatePoint
are, respectively, functions to find a point on the line between the two anchors, and to project a point from the line between the two anchors onto the upper or lower parallel line. The code for these is included in the full code listing at the end of the post.
CONNECTOR CODE
This is the basic skeleton of a custom connector:
import { AbstractConnector, Connectors, StraightSegment } from "@jsplumb/core"
export class TriangleWaveConnector extends AbstractConnector {
static type = "TriangleWave";
type = TriangleWaveConnector.type;
_compute(paintInfo , paintParams ) {
this._addSegment(StraightSegment, { ... params for segment ... });
}
getDefaultStubs() {
return [0,0]
}
transformGeometry(g , dx , dy ){
return g
}
}
Connectors.register(TriangleWaveConnector.type, TriangleWaveConnector);
It has to extend AbstractConnector
, and it needs to implement 3 methods:
_compute
This is where you calculate the segments in your connector.getDefaultStubs
Optional, it tells jsPlumb whether or not you want stubs by default, and if so, how long they should betransformGeometry
Optional, and outside the scope of this article. This is for connectors whose paths can be subsequently manipulated by the user (such as the Orthogonal connector in the Toolkit edition)
Computing segments
The _compute
method is what jsPlumb will call at paint time, and it is the contents of the paintInfo
object you'll be interested in - it contains a lot of parameters, many of which you don't need, but here are the ones you might find useful:
paintInfo: {
sx: 442.6,
sy: 0,
tx: 0,
ty: 51,
startStubX: 442.6,
startStubY: 0,
endStubX: 0,
endStubY: 51,
w: 442.6,
h: 51,
mx: 221.3,
my: 25.5,
opposite: true,
orthogonal: false,
perpendicular: false,
segment: 3,
so: [ 1, -1 ],
to: [ 0, -1 ],
}
The most interesting values in here for the majority of connectors are sx
, sy
, tx
and ty
, which give the location of the source and target anchors. [ sx, sy ]
and [ tx, ty ]
are the equivalent of the anchor1
and anchor2
values in our pseudo code above. A simple straight line connector, for instance, could (and does!) just add a single segment from [sx, sy]
to [tx, ty]
.
So now we have enough to put together the code for the connector - we'll use the skeleton code and plug in our maths.
import { AbstractConnector, Connectors, StraightSegment } from "@jsplumb/core"
export class TriangleWaveConnector extends AbstractConnector {
static type = "TriangleWave"
type = TriangleWaveConnector.type
wavelength = 10
amplitude = 10
constructor(connection, params) {
super(connection, params)
}
_compute(paintInfo, computeParams) {
let dx = paintInfo.tx - paintInfo.sx,
dy = paintInfo.ty - paintInfo.sy,
d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)),
m = Math.atan2(dy,dx),
n = Math.atan2(dx, dy),
origin = [ paintInfo.sx, paintInfo.sy ],
current = [ paintInfo.sx, paintInfo.sy ],
peaks = Math.round(d / this.wavelength),
shift = d - (peaks * this.wavelength),
upper = true;
for (let i = 0; i < peaks - 1; i++) {
let xy = pointOnLine(origin, m, shift + ((i+1) * w)),
pxy = translatePoint(xy, n, upper, this.amplitude);
this._addSegment(StraightSegment, {
x1:current[0],
y1:current[1],
x2:pxy[0],
y2:pxy[1]
});
upper = !upper;
current = pxy;
}
this._addSegment(StraightSegment, {
x1:current[0],
y1:current[1],
x2:paintInfo.tx,
y2:paintInfo.ty
});
};
}
Connectors.register(TriangleWaveConnector.type, TriangleWaveConnector)
And here it is in action. You can drag those boxes around:
CONSTRUCTOR PARAMETERS
So far we have a triangle wave connector with a fixed distance of 10 pixels between the peaks, and a peak height of 10 pixels. What if we want to be able to control these values? For that we'll want to supply constructor parameters. As with the vast majority of objects in jsPlumb, when you specify a Connector type you can supply just the name of the Connector, or you can supply an array of [ name, { parameters }]
. In the second case, jsPlumb will provide the parameters object as an argument to your Connector's constructor. So we might change our usage of the Triangle Wave Connector to specify a 20 pixel gap between the peaks, and a peak height of 7px:
connector:{
type:TriangleWaveConnector.type,
options:{ wavelength:20, amplitude:7 }
}
And then the first few lines of our connector will change to take these parameters into account:
import { AbstractConnector, Connectors } from "@jsplumb/core"
export class TriangleWaveConnector extends AbstractConnector {
static type = "TriangleWave"
type = TriangleWaveConnector.type
wavelength
amplitude
constructor(connection, params) {
super(connection, params)
this.wavelength = params.wavelength || 10
this.wavelength = params.amplitude || 10
}
...
}
Connectors.register(TriangleWaveConnector.type, TriangleWaveConnector)
Here's the code from before, but with a wavelength of 20px, and an amplitude of 7px:
OVERLAYS
There's nothing special you need to do to support overlays; they are handled automatically by AbstractConnector
in conjunction
with the connector segments. Here's the same code again, with a label:
THOSE TRIANGLES LOOK LIKE SPRINGS
Don't they, though? Maybe we could modify the code and make them behave like simple springs too. Let's consider the basic behaviour of a spring: it has a fully compressed state, beyond which it can compress no more, and as you stretch it, the coils separate further and further. Obviously in a real spring, there is a value at which the spring has been stretched beyond the limit at which it can spring back. We're not going to model that here, though. Here we're just going to keep things simple - we'll add a flag defining whether or not to behave like a spring, and define a minimum distance, corresponding to the fully compressed state:
this.wavelength = params.wavelength || 10
this.amplitude = params.amplitude || 10
this.spring = params.spring
this.compressedThreshold = params.compressedThreshold || 5
And let's say that when the two elements are closer than compressedThreshold
, the wavelength
will be 1 pixel. Beyond that, the wavelength will grow as the two elements separate. By how much? I'm going to pull a number out of thin air here and say that when the spring is not fully compressed, the wavelength will be 1/20th of the distance between the two anchors. Actually I should be honest: I didn't pull this number completely out of thin air. I ran it a few times with different values until I found something I liked the look of.
Now I can configure two elements to be connected with a rudimentary spring:
instance.connect({
source:document.getElemenyById("w7"),
target:document.getElementById("w8"),
connector:{
type:TriangleWaveConnector.type,
options:{
spring:true
}
}
});
WHAT ABOUT STUBS? I WANT STUBS.
Some types of connectors benefit from having a first segment that emanates as a straight line from their anchor, before the real business of connecting comes into play. You can see this in the Flowchart demonstration in jsPlumb. Now that our triangle wave connector can behave like a spring, it strikes that me it would be good to support stubs here too. Fortunately, it isn't very hard to do. Remember the sx
/sy
/tx
/ty
parameters from above? If you supply a stub
argument to your connector, paintInfo
also exposes the location of the end of the stubs, via startStubX
/startStubY
/endStubX
/endStubY
.
So we can change the code to use these stub locations as the origin and final point, and then also add a segment for each stub:
let dx = paintInfo.endStubX - paintInfo.startStubX,
dy = paintInfo.endStubY - paintInfo.startStubY,
d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)),
m = Math.atan2(dy, dx),
n = Math.atan2(dx, dy),
origin = [ paintInfo.startStubX, paintInfo.startStubY ],
current = [ paintInfo.startStubX, paintInfo.startStubY ],
...
Here's the result:
instance.connect({
source:document.getElementById("w9"),
target:document.getElementById("w10"),
connector:{
type:TriangleWaveConnector.type,
options:{
spring:true,
stub:[ 20, 20 ]
}
},
anchors:["Right", "Left"]
});
IN SUMMARY
It's pretty straightforward to add a new connector type to jsPlumb. Most of the work is really in the maths underpinning the connector's path. For reference, below is the "triangle wave" connector's code in full (which turned out to be a spring in disguise!).
Whilst working on the spring stuff at the end of this post it occurred to me that a real spring would impose bounds on the two elements it was joining: for instance, the two elements should not be able to be closer than the spring's compressed size, and there is a point at which the spring will refuse to stretch any further. At first I was tempted to think about ways the connector could help model these behaviours, but of course this connector is just the view; decisions about constraining movement do not belong here. Look out for a future post in which I will discuss the general direction jsPlumb is heading in with respect to these sorts of requirements.
And finally, if you make something awesome, please do consider sharing it with others!
FORK ME ON GITHUB
The code for this connector, along with a small test harness, is available at https://github.com/jsplumb-demonstrations/custom-connector-example
THE FINAL CODE
import { AbstractConnector, Connectors, StraightSegment } from "@jsplumb/core"
function translatePoint(from, n, upper, amplitude) {
const dux = isFinite(n) ? (Math.cos(n) * amplitude) : 0;
const duy = isFinite(n) ? (Math.sin(n) * amplitude) : amplitude;
return [
from[0] - ((upper ? -1 : 1) * dux),
from[1] + ((upper ? -1 : 1) * duy)
];
}
function pointOnLine(from, m, distance) {
const dux = isFinite(m) ? (Math.cos(m) * distance) : 0;
const duy = isFinite(m) ? (Math.sin(m) * distance) : distance;
return [
from[0] + dux,
from[1] + duy
];
}
export class TriangleWaveConnector extends AbstractConnector {
static type = "TriangleWave"
type = TriangleWaveConnector.type
wavelength
amplitude
spring
compressedThreshold
constructor(connection, params) {
super(connection, params)
params = params || {}
this.wavelength = params.wavelength || 10
this.amplitude = params.amplitude || 10
this.spring = params.spring
this.compressedThreshold = params.compressedThreshold || 5
}
getDefaultStubs(){
return [0, 0]
}
_compute (paintInfo, paintParams) {
let dx = paintInfo.endStubX - paintInfo.startStubX,
dy = paintInfo.endStubY - paintInfo.startStubY,
d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)),
m = Math.atan2(dy, dx),
n = Math.atan2(dx, dy),
origin = [ paintInfo.startStubX, paintInfo.startStubY ],
current = [ paintInfo.startStubX, paintInfo.startStubY ],
w = this.spring ? d <= this.compressedThreshold ? 1 : d / 20 : this.wavelength,
peaks = Math.round(d / w),
shift = d - (peaks * w),
upper = true;
this._addSegment(StraightSegment, {
x1:paintInfo.sx,
y1:paintInfo.sy,
x2:paintInfo.startStubX,
y2:paintInfo.startStubY
});
for (let i = 0; i < peaks - 1; i++) {
let xy = pointOnLine(origin, m, shift + ((i+1) * w)),
pxy = translatePoint(xy, n, upper, this.amplitude);
this._addSegment(StraightSegment, {
x1:current[0],
y1:current[1],
x2:pxy[0],
y2:pxy[1]
});
upper = !upper;
current = pxy;
}
this._addSegment(StraightSegment, {
x1:current[0],
y1:current[1],
x2:paintInfo.endStubX,
y2:paintInfo.endStubY
});
this._addSegment(StraightSegment, {
x1:paintInfo.endStubX,
y1:paintInfo.endStubY,
x2:paintInfo.tx,
y2:paintInfo.ty
});
}
}
Connectors.register(TriangleWaveConnector.type, TriangleWaveConnector)
Get in touch!
If you'd like to discuss any of the ideas/concepts in this article we'd love to hear from you - drop us a line at hello@jsplumbtoolkit.com.
Not a user of the jsPlumb Toolkit but thinking of checking it out? Head over to https://jsplumbtoolkit.com/trial. It's a good time to get started with jsPlumb.