Port support in Community Edition 4.x Port support in Community Edition 4.x
25 Aug 2020
Nested group support in Community Edition 4.x Nested group support in Community Edition 4.x
28 Jul 2020
Community Edition 4.x Beta Release Community Edition 4.x Beta Release
20 Jun 2020
World Cup 2018, Part 2 World Cup 2018, Part 2
26 Jun 2018
World Cup 2018, Part 1 World Cup 2018, Part 1
25 Jun 2018
Community Edition 2.2.2 Release Community Edition 2.2.2 Release
20 Oct 2016
Connecting SVG Shapes (Raphael, Highcharts etc) (update) Connecting SVG Shapes (Raphael, Highcharts etc) (update)
20 Oct 2016
Writing a Layout Decorator Writing a Layout Decorator
25 Jul 2015
Toolkit Edition 1.0.0 Toolkit Edition 1.0.0
23 Jul 2015
Community Edition 1.7.6 Community Edition 1.7.6
23 Jul 2015
Connecting SVG Shapes (Raphael, Highcharts etc) Connecting SVG Shapes (Raphael, Highcharts etc)
22 Jul 2015
The beforeDrag Interceptor The beforeDrag Interceptor
15 Jan 2015
Integrating Jekyll and YUIDoc Integrating Jekyll and YUIDoc
28 Jan 2014
Dragging Multiple Elements Dragging Multiple Elements
17 Jan 2014
Custom Connectors Custom Connectors
22 Dec 2013

Writing a Layout Decorator

As well as providing support for layouts, the Toolkit allows you to write "decorators": code that runs post-layout and can decorate the UI however you like. In this post we're going to discuss how this can be done.

DATASET

Let's first take a look at the dataset we're going to use - it's in the hierarchical json syntax that is one of the two formats supported out of the box:

var hierarchy =
    {
        "label":"One",
        "children":[
            {
                "label":"Two"
            },
            {
                "label":"Three",
                "children":[
                    {
                        "label":"Four"
                    },
                    {
                        "label":"Five"
                    }
                ]
            }
        ]
    };

NODE TEMPLATE

We have a single node type in this demo, and we don't say anywhere how the type should be derived, so the Toolkit will just assign a type of default to it.

Although it is possible to explicitly map a node type to a template, we don't have to if we don't want to: we can use inferencing to do this for us. So here we have a template with id jtk-template-default; it will be used to render each node. It is very simple - it just draws the node's label:

<script type="jsplumb" id="jtk-template-default">
    <div class="node">
        ${label}
    </div>
</script>

GETTING A TOOLKIT

We get an instance of the Toolkit first:

var toolkit = jsPlumbToolkit.newInstance();

RENDERING

First let's render the data with no decorator:

toolkit.render({
    container:"demo1",
    layout:{ type:"Hierarchical" },
    jsPlumb:{
        Connector:[ "Straight", { stub:10 }],
        Anchors:["Bottom", "Top"],
        Endpoint:"Blank",
        PaintStyle:{ lineWidth:2, stroke:"purple"}
    },
    zoomToFit:true,
    elementsDraggable:false
});

We get this:

NOW FOR SOME DECORATION

Everything is setup and rendering ok - let's think about our decorator. We want to draw alternately coloured
stripes under each level in the hierarchy. The first thing to do is to declare our decorator to the Toolkit, and
write stubs for the two required methods:

jsPlumbToolkit.Layouts.Decorators["Hierarchy"] = function() {

    this.reset = function(params) {

        // Contents of params:
        
        // remove(el, doNotRepaint)
    };

    this.decorate = function(params) {
        
        // Contents of params:
         
        // adapter (a Surface)
        // append(el, id, pos) (function you can call to append something to the canvas)
        // floatElement(el, pos) (function you can call to float something on top of the canvas)
        // fixElement(el, axes, pos) (function you can call to place something on the canvas and restrict its movement in one or both axes)
        // bounds (array of xmin, ymin, xmax, ymax) node positions.
        // jsPlumb (the underlying jsPlumb renderer)
        // layout (the layout used to render this data)
        // setAbsolutePosition(el, xy) (function you can call to set some element's position)
        // toolkit (the underlying Toolkit instance)
    };
    
};

reset is called whenever a relayout is about to occur. decorate is called after a relayout has occurred.

The behaviour of each decorator will vary wildly; it depends on what your UI looks like. Generally, though, the
decorator will know about the underlying layout, and our example is no different. We know we have chosen a
Hierarchical layout, and it is one of these that is passed in to our decorate method.

So, what does this decorator need to know? It needs to know how many levels there are, and the extents of each level.
params.bounds gives us the box that encloses every node, so we know that the minimum and maximum X values are given
by params.bounds[0] and params.bounds[2]. But how tall is each level and where does it start in the Y axis?

The layout gives us this information, via the getHierarchy method:

var hierarchy = params.layout.getHierarchy();

This is an array, one entry for each level, containing otherAxis and otherAxisSize parameters, which give the
start point in the Y axis, and the height, respectively.

These details are of course specific to the Hierarchical layout we are using. If you are writing a decorator for
your own layout, you will need to ensure the layout exposes enough information about itself for your decorator to
be able to do what it needs to.

THE CODE

Our decorate method looks like this:

this.decorate = function(params) {
    if (params.bounds[0] == Infinity) return;
    var hierarchy = params.layout.getHierarchy();
    var w = (params.bounds[2] - params.bounds[0]);
    for (var i = 0; i < hierarchy.length; i++) {
        var bg = document.createElement("div");
        params.append(bg);
        bg.className = "level " + (i % 2 ? "odd" : "even");
        bg.style.width = (w + 100) + "px";
        bg.style.height = hierarchy[i].otherAxisSize + "px";
        var levelBounds = [params.bounds[0] - 50, hierarchy[i].otherAxis - (hierarchy[i].otherAxisSize / 4)];
        params.setAbsolutePosition(bg, levelBounds);
    }
};

We start by testing that we're decorating a dataset that has known bounds. If so, we compute the total width, then
we call getHierarchy and iterate through the return value.

At each level we create a div element, assign it an odd or even class, then set its width to w +100 pixels. This
is just so that the decoration extends a little beyond the nodes, for aesthetic appeal. We're adding 50 pixels
padding, basically.

We then set the height of the level background to be otherAxisSize, and compute its bounds in the x axis, we'll
place the start of it 50 pixels to the left of the leftmost node (half of the 100 we added to the full width above), and
in the Y axis we start the level at a quarter of its width above the nodes.

Now with this code:

toolkit.render({
    container:"demo2",
    layout:{
        type:"Hierarchical",
        decorators:[ "Hierarchy" ]
    },
    jsPlumb:{
        Connector:[ "Straight", { stub:10 }],
        Anchors:["Bottom", "Top"],
        Endpoint:"Blank",
        PaintStyle:{ lineWidth:2, stroke:"purple"}
    },
    zoomToFit:true,
    elementsDraggable:false
});

We get this output:

PARAMETERS

Did hardcoding 50 pixels padding make you feel queasy? It did me. Fortunately you can provide constructor parameters for
your decorators, using the syntax you may be familiar with from Community jsPlumb:

toolkit.render({
    container:"demo3",
    layout:{
        type:"Hierarchical",
        decorators:[
            ["Hierarchy", { padding:50 }]
        ]
    },
    jsPlumb:{
        Connector:[ "Straight", { stub:10 }],
        Anchors:["Bottom", "Top"],
        Endpoint:"Blank",
        PaintStyle:{ lineWidth:2, stroke:"purple"}
    },
    zoomToFit:true,
    elementsDraggable:false
});

The JS object containing the padding value will be passed in to the decorator's constructor:

jsPlumbToolkit.Layouts.Decorators["Hierarchy"] = function(options) {

    var els = [];
    var padding = options.padding || 50;   
    ...    
    bg.style.width = (w + (padding * 2 )) + "px";
    ...                
    var levelBounds = [params.bounds[0] - padding, hierarchy[i].otherAxis - (hierarchy[i].otherAxisSize / 4)];
    ...
};

FLOATED/FIXED DECORATION

So far we've seen only decoration that pans and zooms with the content, appended via the append method passed in
via the params argument to the decorate method. We can also append elements that will be floated above the content,
at absolute positions, or rendered with the content but which are fixed so as to never disappear from the viewport.

For these last two cases we'll write another decorator - a "label" decorator:

jsPlumbToolkit.Layouts.Decorators["Label"] = function(options) {

    var el;
    var left = options.left || 50, top = options.top || 50;

    this.reset = function(params) {
        el && params.remove(el);
    };

    this.decorate = function(params) {
        if (params.bounds[0] == Infinity) return;
        el = document.createElement("div");
        el.className = "floatedLabel" + (options.cssClass ? " " + options.cssClass : "");
        el.innerHTML = options.label;

        if (options.fix) {
            params.fixElement(el, options.fix, [ left, top ]);
        }
        else {
            params.floatElement(el, [ left, top ]);
        }
    };
};

Looking through this code you'll see we support user-specified left and top positions, as well as a custom class
name. But what does fix do? Consider this code:

 toolkit.render({
     container:"demo3",
     layout:{
         type:"Hierarchical",
         decorators:[
             ["Hierarchy", { padding:50 }],
             [ "Label", { label:"Floating Decorator", left:300, top:50 }],
             [ "Label", { 
                label:"X Fixed", 
                fix:{ left:true }, 
                left:-80, top:30, 
                cssClass:"fixed-label" 
             }]
         ]
     },
     jsPlumb:{
         Connector:[ "Straight", { stub:10 }],
         Anchors:["Bottom", "Top"],
         Endpoint:"Blank",
         PaintStyle:{ lineWidth:2, stroke:"purple"}
     },
     zoomToFit:true,
     elementsDraggable:false
 });

Our output now looks like this:

So, as you pan/zoom the content, the 'Floating Decorator' label stays put. Useful for appending, well, labels, I guess, but also for control elements. The 'X Fixed' decorator pans with the content but is locked to the viewport - try dragging the content all the way over to the left. You might use this, for example, as a way to label a swim lane in a process flow diagram.

A STEP FURTHER: ORIENTATION

The Hierarchical layout can be rendered in either horizontal or vertical orientations. Our code so far assumes
horizontal, but with a small refactor we can have it support either:

this.decorate = function(params) {
    if (params.bounds[0] == Infinity) return;
    var hierarchy = params.layout.getHierarchy();
    var o = params.layout.getOrientation(),
        axisInfo = o === "horizontal" ? 
                [ "width", "height", params.bounds[2] - params.bounds[0] ] :
                [ "height", "width", params.bounds[3] - params.bounds[1]];

    for (var i = 0; i < hierarchy.length; i++) {
        var bg = document.createElement("div");
        params.append(bg);
        bg.className = "level " + (i % 2 ? "odd" : "even");
        bg.style[axisInfo[0]] = (axisInfo[2] + (padding * 2 )) + "px";
        bg.style[axisInfo[1]] = hierarchy[i].otherAxisSize + "px";
        var levelBounds = o === "horizontal" ?
            [params.bounds[0] - padding, hierarchy[i].otherAxis - (hierarchy[i].otherAxisSize / 4)] :
            [ hierarchy[i].otherAxis - (hierarchy[i].otherAxisSize / 4), params.bounds[1] - padding ];
        params.setAbsolutePosition(bg, levelBounds);
    }
};

Now with this call:

toolkit.render({
    container:"demo4",
    layout:{
        type:"Hierarchical",
        parameters:{
            orientation:"vertical"
        },
        decorators:[
            ["Hierarchy", { padding:50 }]
        ]
    },
    jsPlumb:{
        Connector:[ "Straight", { stub:10 }],
        Anchors:["Right", "Left"],
        Endpoint:"Blank",
        PaintStyle:{ lineWidth:2, stroke:"purple"}
    },
    zoomToFit:true,
    elementsDraggable:false
});

We get this output: