Disclaimer - The following tutorial was created with a past version of JointJS. The tutorial is still provided for those who may face a similar problem, but it may no longer show the best practices of JointJS. You may encounter the following issues:
Our current recommendations on best practices can be found in the appropriate sections of the basic and intermediate tutorials.
TL;DR: Jump right into the demo below.
This tutorial shows another way how to style JointJS links, especially if only changing the stroke color and width of your links is not sufficient to meet your requirements. What gives us more flexibility is SVG gradients and SVG patterns. SVG gradients as filling for strokes would be fine enough if we were using only straight links with no vertices. This is because the gradients are applied on the link SVG path as a whole. Therefore, applying the gradient on the link SVG path stroke would give different parts of the links different colors depending on how the link is broken and where it is positioned. This gives us an unpredictable result that we most likely don't want. Meet SVG patterns!
The idea is simple. We create and render a link. We create an HTML 5 canvas element of the size of the bounding box of the link. We draw onto the canvas anything we'd like the link to look like. We generate a Data URI image from the canvas and tell the link to use this image as an SVG pattern for the link stroke. And we repeat all this every time the link gets changed.
A link view is responsible for rendering and updating a link, it manipulates the DOM elements and it is the right place to implement a link interaction or customize the link appearance.
Each time we change a link attribute, source or target, we add a vertex or we move an element that is
connected to the link - the link has to be updated.
Normally, the joint.dia.LinkView takes care of this.
It inherits from the Backbone.View and extends it
by new methods and properties. We're going to introduce some of them.
The linkView.render()
method creates SVG elements from the defined link markup and appends
them to the DOM.
It's usually called only once when link is created. Also, it calls update() internally.
The linkView.update()
method applies all the attributes to the DOM elements, finds the
route, positions the link tools and arrowheads and so on. It is called everytime the link is changed.
The linkView.remove()
cleans up SVG elements from the DOM and removes event handlers.
Called once the view is removed.
The linkView.sourcePoint
and linkView.targetPoint
are cached coordinates of a
point where the link connects to an element or point.
The linkView.route
is a cached array of points calculated from the vertices by a
router.
The linkView.paper
is a reference to the paper the view is rendered into.
The joint.dia.LinkView
can be extended and used, for example, in the following way:
joint.dia.LinkView.extend({
render: function() {
// call parent's render
joint.dia.LinkView.prototype.render.apply(this, arguments);
// here we create and append the pattern into the paper SVG <defs> element
// and tell the link to use it
// it is a good convetion to return `this` to enable chaining
return this;
},
remove: function() {
// call parent's remove first
joint.dia.LinkView.prototype.remove.apply(this, arguments);
// here we remove the pattern from the paper SVG <defs> element
return this;
},
update: function() {
// call parent's update first
joint.dia.LinkView.prototype.update.apply(this, arguments);
// here we generate an image and set it as a pattern
return this;
}
});
First of all we have to create an SVG pattern with an image element inside and append it to the DOM
(specifically into the paper SVG
<defs>
element - this is a good place in SVG documents where referenced elements
are defined).
The perfect place for this is the linkView.render()
method.
Here we can also cache some important elements we will be using during the updates in order to minimize DOM
traversal.
render: function() {
joint.dia.LinkView.prototype.render.apply(this, arguments);
// make sure that pattern doesn't already exist
if (!this.pattern) {
// create the pattern and the image element
this.pattern = V('<pattern id="pattern-' + this.id + '" patternUnits="userSpaceOnUse"><image/></pattern>');
// cache the image element for a quicker access
this.patternImage = this.pattern.findOne('image');
// append the pattern to the paper's defs
V(this.paper.svg).defs().append(this.pattern);
}
// tell the '.connection' path to use the pattern
var connection = V(this.el).findOne('.connection').attr({
stroke: 'url(#pattern-' + this.id + ')'
});
// cache the stroke width
this.strokeWidth = connection.attr('stroke-width') || 1;
return this;
}
Note that we're using the built-in Vectorizer library for creating SVG.
Once we are able to get the link's bounding box (the one without transformations), we can create an HTML 5
canvas of the size of the bounding box of the link.
We know the points which the link goes through (sourcePoint
, targetPoint
and
route
) so the next thing is to transform them into the link coordinate system (the coordinates
of the link top-left corner are obtained from its bounding box).
For example if we have a bounding box { x: 100, y: 30, width: 200, height: 200 }
and a
vertex with coordinates { x: 150, y: 150 }
the position of that vertex on the canvas is {
x: 50, y: 120 }
.
Now we have all we need to be able to draw our pattern into the canvas (more info on how to draw into the
canvas can be found here).
xlink:href
attribute of the image element inside the pattern to the data URI containing
a representation of the image in the PNG format (we can obtain it from the canvas by calling canvas.toDataURL('image/png')
).
update: function() {
joint.dia.LinkView.prototype.update.apply(this, arguments);
var strokeWidth = this.strokeWidth;
// we get the bounding box of the linkView without the transformations
// and expand it to all 4 sides by the stroke width
// (making sure there is always enough room for drawing,
// even if the bounding box was tiny.
// Note that the bounding box doesn't include the stroke.)
var bbox = g.rect(V(this.el).bbox(true)).moveAndExpand({
x: - strokeWidth,
y: - strokeWidth,
width: 2 * strokeWidth,
height: 2 * strokeWidth
});
// create an array of all the points the link goes through
// (route doesn't contain the connection points)
var points = [].concat(this.sourcePoint, this.route, this.targetPoint);
// transform all the points to the link coordinate system
points = _.map(points, function(point) {
return g.point(point.x - bbox.x, point.y - bbox.y);
});
// create a canvas of the size same as the link bounding box
var canvas = document.createElement('canvas');
canvas.width = bbox.width;
canvas.height = bbox.height;
var ctx = canvas.getContext('2d');
ctx.lineWidth = strokeWidth;
// iterate over the points and draw the link's new look into the canvas
for (var i = 0, pointsCount = points.length - 1; i < pointsCount; i++) {
var from = points[i];
var to = point[i + 1];
// draw something into the canvas
// e.g a line from 'from.x','from.y' to 'to.x','to.y'
}
// generate data URI from the canvas
var dataUri = canvas.toDataURL('image/png');
// set the pattern's size to the size of the link bounding box
this.pattern.attr(bbox);
// update the pattern image and the dimensions
this.patternImage.attr({
width: bbox.width,
height: bbox.height,
'xlink:href': dataUri
});
return this;
}
It's a good practice to clean up when the link gets removed from the paper. We don't want to leave unreferenced pattern elements in the DOM.
remove: function() {
joint.dia.LinkView.prototype.remove.apply(this, arguments);
// remove the pattern from the DOM
this.pattern.remove();
}
There is one more thing we have to deal with. According to the SVG specification it is not possible to apply patterns on elements with no width or no height.
Keyword objectBoundingBox should not be used when the geometry of the applicable element has no width or no height, such as the case of a horizontal or vertical line, even when the line has actual thickness when viewed due to having a non-zero stroke width since stroke width is ignored for bounding box calculations. When the geometry of the applicable element has no width or height and objectBoundingBox is specified, then the given effect (e.g., a gradient or a filter) will be ignored.
That means, in our case, that we can't use patterns for drawing pure vertical and pure horizontal links.
<!-- pure vertical path (height 0)-->
<path d="M 0 0 L 300 0"/>
<!-- pure horizontal path (width 0)-->
<path d="M 100 0 100 50 100 100"/>
To overcome this issue, we can offset one of the path points by a small number.
<!-- vertical path (height 0.01) -->
<path d="M 0 0 L 300 0.01"/>
<!-- horizontal path (width 0.01)-->
<path d="M 100 0 100 50 100.01 100"/>
The best place where to deal with this is the link connector (connectors are responsible for generating the
link path by constructing its d
attribute).
Here we take joint.connectors.normal
, change the method name to normalDimFix
and
add a very small number to x and y coordinates of the last point of the resulting path.
joint.connectors.normalDimFix = function(sourcePoint, targetPoint, vertices) {
var dimensionFix = 1e-3;
var d = ['M', sourcePoint.x, sourcePoint.y];
_.each(vertices, function(vertex) { d.push(vertex.x, vertex.y); });
d.push(targetPoint.x + dimensionFix, targetPoint.y + dimensionFix);
return d.join(' ');
};
Let's combine all this together and create a link which looks like a pipe.
The demo below contains all that we described so far, plus it is adding some new features.
The LinkView draws into the canvas and updates the pattern asynchronously (using
joint.util.nextFrame).
It automatically creates a gradient with a directions perpendicular to the path direction for each link
segment. This way you can style your link from the 'border' to 'border' and draw an image like the one
below.
update()
into a new linkView method drawPattern()
.The source code to the demo.