This is the fourth article of the intermediate section of the JointJS tutorial. Return to serialization. See index of basic and intermediate articles.
JointJS comes with several collections of built-in element shapes.
We already saw two members of the joint.shapes.standard
collection in the basic
element and links demos.
However, even though there are many default shapes to choose from, you might find that you are missing
one and want to create your own shape definition.
Creating new shapes is very simple in JointJS if you already know SVG.
The most important SVG elements are rect
, text
, circle
,
ellipse
, image
and path
.
Their thorough description with examples and illustrations can be found elsewhere on the Internet, e.g.
on MDN.
What is important for us is to realize that combining the basic SVG shapes, we can define any 2D shape
we need.
If you like video, the following youtube content will show you how to create custom JointJS elements using SVG. This tutorial will still provide you with a comprehensive guide to defining custom shapes, even if you decide to watch the video or not.
To define any custom element, we can use a built-in JointJS function:
Element.define(type [, defaultInstanceProperties, prototypeProperties, staticProperties])
- define a new subtype of this Element
class.If you want to create an Element subtype from scratch, you should inherit from the default
joint.dia.Element
class by calling joint.dia.Element.define()
.
You can also inherit from any shape in the predefined JointJS shape collections (e.g.
joint.shapes.standard.Rectangle.define()
) and even any custom element subtype you have
previously defined.
Here is how the parameters of the define()
function map to familiar building blocks of
elements:
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's use the familiar
joint.shapes.standard.Rectangle
shape definition as an example:
joint.dia.Element.define('standard.Rectangle', {
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)',
strokeWidth: 2,
stroke: '#000000',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}, {
markup: [{
tagName: 'rect',
selector: 'body',
}, {
tagName: 'text',
selector: 'label'
}]
});
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.Rectangle
has to
be 'standard.Rectangle'
.
var namespace = joint.shapes;
var graph = new joint.dia.Graph({}, { cellNamespace: namespace });
new joint.dia.Paper({
el: document.getElementById('paper-custom-elements'),
model: graph,
width: 600,
height: 400,
cellViewNamespace: namespace
});
joint.dia.Element.define('standard.Rectangle', {
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-elements-namespace'),
model: graph,
width: 600,
height: 100,
cellViewNamespace: customNamespace
});
var Shape = joint.dia.Element.define('shapeGroup.Shape', {
attrs: {
// Attributes
}
}, {
markup: [{
// Markup
}]
});
Object.assign(customNamespace, {
shapeGroup: {
Shape
}
});
graph.fromJSON({
cells: [
{
"type": "shapeGroup.Shape",
"size": { "width": 500, "height": 50 },
"position": { "x": 50, "y": 25 },
"attrs": {
"text": {
"text": "customNamespace.shapeGroup.Shape"
}
}
}
]
});
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.Shape
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 element.
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 element 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).
More advanced markup.attributes
are explored in the custom
links tutorial.
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.
We can see that joint.shapes.standard.Rectangle
is composed of two subelements - an
SVGRectElement named body
and an SVGTextElement named label
:
{
markup: [{
tagName: 'rect',
selector: 'body',
}, {
tagName: 'text',
selector: 'label'
}]
}
Here's an equivalent markup using the svg tagged template utility:
{
markup: util.svg`<rect @selector="body"/><text @selector="label"/>`
}
Selectors are important for the targeting of subelements by element attributes.
// set the <rect/> color to red
el.attr('body/fill', 'red');
Although providing a selector to identify a subelement is not strictly required, it makes JointJS
noticeably faster because it can avoid querying the DOM.
If you really do not want to invent a custom name for the subelement selector, you can use the
subelement's tagName if you set useCSSSelectors
to true
on the element prototype.
(For up to one subelement per tagName; selector names have to be unique.)
{
useCSSSelectors: true,
markup: [{
tagName: 'rect',
}, {
tagName: 'text',
className: 'label'
}]
}
el.attr('rect/fill', 'red');
el.attr('.label/fill', 'blue');
Default attributes are usually provided inside the second argument of the define()
function
(default instance properties).
(This is because all instances of the element 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.Rectangle
, we can see that the subelement referenced by
body
(i.e. the SVGRectElement component of the shape) has its default width and height set
in terms of the shape model size (using the calc()
function). Read more about calc()
in our attributes
documentation. Alongside sizing, you can see the fill and stroke styling.
The label
subelement (the SVGTextElement component of the shape) has its text anchor set to
the center of the text bbox; that point is then positioned into the center of the model bbox by x
and y
attributes.
Its font size is specified, and the font fill color:
{
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)',
strokeWidth: 2,
stroke: '#000000',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}
We mentioned model size - the dimensions of the body
depend on it - but what is it?
Model size is set by the user, whenever they call the
element.resize()
function on an
instance of joint.shapes.standard.Rectangle
.
For example, if we used element.resize(100, 100)
, the referenced size would be 100px by
100px; consequently, if no transformations were applied, the body
would have the dimensions
(100,100).
The model bbox is invisible on its own; it only becomes visible through subelements that reference it.
Here, that subelement is the body
subelement.
There does not need to be a direct mapping between model size and any visible subelements.
It is perfectly possible to replace relative sizing using calc()
with regular values
for width
and height
respectively; this would create a
subelement with constant dimensions - 20px by 20px, for example - regardless of model size.
That may be ideal for control buttons.
Similarly, the position of the label
anchor is defined in terms of model size and position.
Model position is modified by calling the
element.position()
function on
an instance of joint.shapes.standard.Rectangle
.
For example, if we called element.position(-100, -100)
, the origin of the reference
coordinates would move to the point (-100,-100) on the paper.
We can see that the anchor of the label
of joint.shapes.standard.Rectangle
in our example is placed centrally. We use calc()
to place the label relative to our
shape model. If the model size was kept at (100, 100), that would mean that the anchor of the label
will be offset from the reference origin by (50, 50) when using x: 'calc(0.5*w)'
and
y: 'calc(0.5*h)'
.
Relative positioning and sizing of elements is explained in more detail in a separate section of the tutorial.
Static properties are not used by joint.shapes.standard.Rectangle
, but let's discuss them a
little bit to gain a complete overview of custom elements.
Imagine we wanted to define our own subtype of joint.shapes.standard.Rectangle
(which we
name 'examples.CustomRectangle'
), with the added benefit of a constructor function that
chose a random style for the rectangle body - maybe because we need to add a lot of diverse rectangle
shapes quickly.
We could do this with a static function createRandom
; then, we would have two ways to
obtain an instance of CustomRectangle:
With the standard constructor ...
var customRectangle = new joint.shapes.examples.CustomRectangle();
... or with the new static function:
var customRectangle = joint.shapes.examples.CustomRectangle.createRandom();
Both of these functions are demonstrated in our example.
Let's apply everything we learned so far and create a new
joint.shapes.examples.CustomRectangle
class based on
joint.shapes.standard.Rectangle
.
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.Rectangle.define('examples.CustomRectangle', {
attrs: {
body: {
rx: 10, // add a corner radius
ry: 10,
strokeWidth: 1,
fill: 'cornflowerblue'
},
label: {
textAnchor: 'left', // align text to left
x: 10, // offset text from right edge of model bbox
fill: 'white',
fontSize: 18
}
}
}, {
// inherit joint.shapes.standard.Rectangle.markup
}, {
createRandom: function() {
var rectangle = new this();
var fill = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6);
var stroke = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6);
var strokeWidth = Math.floor(Math.random() * 6);
var strokeDasharray = Math.floor(Math.random() * 6) + ' ' + Math.floor(Math.random() * 6);
var radius = Math.floor(Math.random() * 21);
rectangle.attr({
body: {
fill: fill,
stroke: stroke,
strokeWidth: strokeWidth,
strokeDasharray: strokeDasharray,
rx: radius,
ry: radius
},
label: { // ensure visibility on dark backgrounds
fill: 'black',
stroke: 'white',
strokeWidth: 1,
fontWeight: 'bold'
}
});
return rectangle;
}
});
We add our shapes to the graph in the same manner described in our basic Graph & Paper
tutorial. In the code below, we add a joint.shapes.standard.Rectangle
with rounded corners and
a Multiline label. The Multiline label respects the x
and y
values that we
calculated with the calc()
function earlier.
var rect2 = new joint.shapes.standard.Rectangle();
rect2.position(50, 125);
rect2.resize(500, 50);
rect2.attr({
body: {
rx: 10, // add a corner radius
ry: 10,
fill: '#ADD8E6'
},
label: {
text: 'shapes.\nstandard.Rectangle()' // add Multiline label with Newline character
}
});
rect2.addTo(graph);
The following example shows the default look of joint.shapes.standard.Rectangle
(i.e. with
no instance attributes set), and the Multiline label example above. Alongside those, you can see the default
look of our custom element, and the randomized results of the createRandom()
constructor function.
Try refreshing the page to see
createRandom()
in action.
JointJS source code: custom-elements.js
JointJS source code: custom-elements-namespace.js
Now that we know how to create custom element models, let's learn about custom links.