This is the fifth article of the intermediate section of the JointJS tutorial. Return to custom elements. See index of basic and intermediate articles.
JointJS comes with several built-in link shapes; we have already encountered
joint.shapes.standard.Link
in the basic links tutorial.
However, built-in link definitions are not as plentiful as element definitions.
It is thus very possible that you will find the need to define your own custom link type.
Creating custom links is very similar to creating custom elements,
with a few differences.
The Link class is discussed in JointJS documentation,
as well.
A new Link subtype is registered by calling the define()
function:
Link.define(type [, defaultInstanceProperties, prototypeProperties, staticProperties])
- define a new subtype of this Link
class.If you want to create a Link subtype from scratch, you should inherit from the default
joint.dia.Link
class by calling joint.dia.Link.define()
.
You can also inherit from any predefined link (e.g. joint.shapes.standard.Link.define()
)
and even any custom link subtype you have previously defined.
Here is how the parameters of the define()
function map to familiar building blocks of
links:
type
- the name of the subtype class.defaultInstanceProperties
- object that contains properties to be assigned to every
constructed instance of the subtype.
Used for specifying default attributes.prototypeProperties
- object that contains properties to be assigned on the subtype
prototype.
Intended for properties intrinsic to the subtype, not usually modified.
Used for specifying shape markup.staticProperties
- object that contains properties to be assigned on the subtype
constructor.
Not very common, used mostly for alternative constructor
functions.Let us use the familiar
joint.shapes.standard.Link
shape definition as an example:
joint.dia.Link.define('standard.Link', {
attrs: {
line: {
connection: true,
stroke: '#333333',
strokeWidth: 2,
strokeLinejoin: 'round',
targetMarker: {
'type': 'path',
'd': 'M 10 -5 0 0 10 5 z'
}
},
wrapper: {
connection: true,
strokeWidth: 10,
strokeLinejoin: 'round'
}
}
}, {
markup: [{
tagName: 'path',
selector: 'wrapper',
attributes: {
'fill': 'none',
'cursor': 'pointer',
'stroke': 'transparent'
}
}, {
tagName: 'path',
selector: 'line',
attributes: {
'fill': 'none',
'pointer-events': 'none'
}
}]
});
The first argument of the define()
function is a unique identifier under which you want to
be able to find the new type.
The first part of the name, joint.shapes
, is implied.
Thus, we can see that the name of a type accessed as joint.shapes.standard.Link
has to
be 'standard.Link'
.
var namespace = joint.shapes;
var graph = new joint.dia.Graph({}, { cellNamespace: namespace });
new joint.dia.Paper({
el: document.getElementById('paper-custom-links'),
model: graph,
width: 600,
height: 300,
cellViewNamespace: namespace
});
joint.dia.Link.define('standard.Link', {
attrs: {
// Attributes
}
}, {
markup: [{
// Markup
}]
});
By default, JointJS reads cell definitions from the joint.shapes
namespace. If for some reason you would like to change this behaviour,
it is possible to do so. We can achieve this by combining the cellNamespace
and cellViewNamespace
options which can be found
on graph and paper respectively. Let's see how that might look.
var customNamespace = {};
var graph = new joint.dia.Graph({}, { cellNamespace: customNamespace });
new joint.dia.Paper({
el: document.getElementById('paper-custom-links-namespace'),
model: graph,
width: 600,
height: 100,
cellViewNamespace: customNamespace
});
var Link = joint.dia.Link.define('shapeGroup.Link', {
attrs: {
// Attributes
}
}, {
markup: [{
// Markup
}]
});
Object.assign(customNamespace, {
shapeGroup: {
Link
}
});
graph.fromJSON({
cells: [
{
"type": "shapeGroup.Link",
"source": { "x": 100, "y": 50 },
"target": { "x": 500, "y": 50 }
}
]
});
graph.getLinks()[0].appendLabel({
attrs: {
text: {
text: 'customNamespace.shapeGroup.Link'
}
}
});
As you can see, type
is very important, especially if you want to import a graph from JSON.
In the example above, graph
looks at the customNamespace.shapeGroup.Link
path to find the correct constructor.
If we were to use the incorrect type in graph.fromJSON()
, that would mean graph is unable to find the correct constructor,
and we wouldn't see our custom link.
Markup is usually provided inside the third argument of the define()
function (prototype
properties) for improved performance.
(This is because markup is something that all instances of the link type are expected to have in
common, so inheriting from the subtype prototype is more efficient.
Nevertheless, it is still possible to provide custom markup to individual instances of your class by
providing individual markup
later).
The markup
property is specified as a JSON array.
Each member of the array is taken to define one subelement of the new shape.
Subelements are defined with objects containing a tagName
(a string with the SVG tag
name of the subelement) and a selector
(a string identifier for this subelement
in the shape).
Although JointJS can also understand SVG markup in string form, that approach is noticeably slower due
to the need for parsing and lack of capacity for custom selectors.
A joint.shapes.standard.Link
is composed of two subelements - an
SVGPathElement named line
and an SVGPathElement named wrapper
.
The line
is the subelement that is supposed to be styled, while wrapper
is a
transparent helper subelement underneath the line
that is designed to be interacted
with.
{
markup: [{
tagName: 'path',
selector: 'wrapper',
attributes: {
'fill': 'none',
'cursor': 'pointer',
'stroke': 'transparent'
}
}, {
tagName: 'path',
selector: 'line',
attributes: {
'fill': 'none',
'pointer-events': 'none'
}
}]
}
As you can see, attributes
can also be defined inside markup.
These are intended for attributes that all instances of a link type are expected to have in common;
for such attributes, it is more efficient to inherit these attributes from the subtype prototype instead
of each instance having their own copy because it avoids unnecessary iterations in the cell attributes
update loop.
(Nevertheless, again, it is possible to overwrite these prototype attributes by individual instance
attributes if absolutely necessary).
In our example, markup.attributes
are used to reflect the intended use of the two
subelements of the link, which is not expected to change - wrapper
has a transparent stroke
since it is not expected to be styled, while line
has 'pointer-events'
deactivated since it is not expected to be interacted with.
Both subelement paths also remove the default SVGPathElement 'fill'
which is not usually
terribly useful within links.
An important caveat is that markup.attributes
can only store native SVG attributes,
not JointJS attrs.
This means that JointJS special attributes are not recognized (since markup.attributes
are not
supposed to change, they would not be able to reflect possible changes in referenced subelements or in the
size/position of model bbox).
Default attributes are usually provided inside the second argument of the define()
function
(default instance properties).
(This is because all instances of the link type are expected to have their own individual attributes, so
inheriting from the prototype is likely to cause unnecessary overhead).
In the attrs
object, keys correspond to subelement selectors, as defined in the
markup.
For each subelement, an object with attribute name-value pairs is then expected.
Each of these attributes can be a native SVG attribute or a JointJS
special attribute.
In joint.shapes.standard.Link
, we can see that the subelement referenced by
line
(i.e. the stylable SVGPathElement component of the link) has a default dark-gray
stroke of width 2
, and a default target marker that looks like a small arrow.
(Since the default target marker has no fill
or stroke
set, the colors are
inherited from the line.stroke
attribute by default.)
The wrapper
subelement (the interactive SVGPathElement component of the link) has a wider
transparent stroke to make the link easier to click for users.
Finally, notice that both subelements have the connection
special attribute set to
true
.
This means that they both automatically follow the connection path of the link instance as calculated by
the link geometry functions:
{
attrs: {
line: {
connection: true,
stroke: '#333333',
strokeWidth: 2,
strokeLinejoin: 'round',
targetMarker: {
'type': 'path',
'd': 'M 10 -5 0 0 10 5 z'
}
},
wrapper: {
connection: true,
strokeWidth: 10,
strokeLinejoin: 'round'
}
}
}
The second argument of the define()
function (default instance properties) is also where
defaultLabel
for custom Link subtypes may be specified.
This allows you to provide custom default markup, size, attrs and position for labels that are created
on an instance of your custom Link type.
The defaultLabel
property is explained in our
documentation.
The joint.shapes.standard.Link
does not define its own custom default
label, so the
built-in default markup,
attributes and position are used unless they are overridden by individual label markup
,
attrs
and position
.
The defaultLabel
accepts four optional properties:
defaultLabel.markup
- sets the default markup of labels created on this Link subtype.
Expected in the JSON markup format or as a joint.util.svg
ES6 tag template.
(An SVG-parsable string is also accepted, but it is slower.)
See default.defaultLabel.size
- sets the default dimensions of labels created on this Link subtype.
No default value.defaultLabel.attrs
- sets the default attributes of label subelements on this Link
subtype.
Uses selectors defined in markup
.
See default.defaultLabel.position
- sets the default position along the line at which labels will
be added for instances of this Link subtype.
The default is
{distance: 0.5}
(midpoint of the connection path).Link labels are explained in depth in a separate section of the intermediate tutorial.
Static properties are not used by joint.shapes.standard.Link
, but let's discuss them a
little bit to gain a complete overview of custom links.
Similarly to our custom element example, imagine we
wanted to define our own subtype of joint.shapes.standard.Link
(which we name
'examples.CustomLink'
), with the added benefit of a constructor function that
chose a random style for the link's line - maybe because we need to add a lot of diverse links quickly.
We could do this with a static function createRandom
; then, we would have two ways to
obtain an instance of CustomLink:
With the standard constructor ...
var customLink = new joint.shapes.examples.CustomLink();
... or with the new static function:
var customLink = joint.shapes.examples.CustomLink.createRandom();
Both of these functions are demonstrated in our example.
Let us apply everything we learned so far and create a new
joint.shapes.examples.CustomLink
class based on joint.shapes.standard.Link
.
Keep in mind that the provided instance properties are mixined with the parent definition, but prototype
and static properties are not.
This means that it is enough for us to record only the attributes that changed in the definition of the
custom element subtype (however, if we wanted to change the markup, we would have to do so
explicitly).
joint.shapes.standard.Link.define('examples.CustomLink', {
attrs: {
line: {
stroke: 'cornflowerblue',
strokeWidth: 5,
targetMarker: {
'type': 'rect',
'width': 10,
'height': 20,
'y': -10,
'stroke': 'none'
}
}
},
defaultLabel: {
markup: [
{
tagName: 'rect',
selector: 'body'
}, {
tagName: 'text',
selector: 'label'
}
],
// no `size` object provided = calc() operations need `ref` property
attrs: {
label: {
fill: 'black', // default text color
fontSize: 12,
textAnchor: 'middle',
yAlignment: 'middle',
pointerEvents: 'none'
},
body: {
// calc() is responsive to size of 'label':
ref: 'label', // subelement identified by 'label' selector
fill: 'white',
stroke: 'cornflowerblue',
strokeWidth: 2,
width: 'calc(1.2*w)',
height: 'calc(1.2*h)',
x: 'calc(x-calc(0.1*w))',
y: 'calc(y-calc(0.1*h))'
}
},
position: {
distance: 100, // default absolute position
args: {
absoluteDistance: true
}
}
}
}, {
// inherit joint.shapes.standard.Link.markup
}, {
createRandom: function() {
var link = new this();
var stroke = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6);
var strokeWidth = Math.floor(Math.random() * 10) + 1;
var strokeDasharray = (Math.floor(Math.random() * 5) + 1) + ' ' + (Math.floor(Math.random() * 5) + 1);
link.attr({
line: {
stroke: stroke,
strokeWidth: strokeWidth,
strokeDasharray: strokeDasharray
}
});
link.prop('defaultLabel/attrs/body/stroke', stroke);
return link;
}
});
We did not specify any defaultLabel.size
object in our example, so JointJS was missing
overarching reference width and height dimensions for use in the various attrs/body
calc()
operations.
In order to use calc()
operations anyways - specifically on attrs/body
- we
instead provided a ref
special
attribute to identify a reference subelement from which the dimensions could be determined.
In our example, the reference subelement was the label
SVGTextElement, so JointJS used its
dimensions for the calc()
operations inside attrs/body
.
(If we had specified neither defaultLabel.size
nor an individual ref
attribute
on attrs/body
, however, the calculations would have used 0
as the reference
width and height, which would have been unexpected.)
Additionally, note that in our example the x
and y
position of the
body
subelement is also determined by reference to the label
subelement - via
calc()
operations which
refer to the x
and y
variables.
The following example shows the default look of joint.shapes.standard.Link
(i.e. with no
instance attributes set) alongside the default look of our custom link and the randomized results of the
createRandom()
constructor function.
Every link has a default label added as well.
Try refreshing the page to see
createRandom()
in action.
JointJS source code: custom-links.js
JointJS source code: custom-links-namespace.js
In the next section of the intermediate tutorial, we will learn about link labels.