Highcharts Plugin: Splines with Grouped Columns

Just recently, I have a similar need and came across with this one:

http://stackoverflow.com/questions/20356456/splines-with-grouped-columns-highcharts.

Even though, I am using a different set of data but somehow our concerns are the same.

  • Combine Splines with Grouped Columns in HighCharts
  • Spline point should be centered on each respective column
  • Spline points aligned to each respective column should also hide or show when that column becomes hidden or shown.

Data Sample

var nseries = [{
            name: "Forms (late)",
            data: [1, 2, 1, 2, 3],
            stack: 0,
            color: '#000000'
        },{
            name: "Gate Pass (late)",
            data: [1, 2, 3, 1, 2],
            stack: 1,
            color: '#000000'
        },{
            name: "Hardware (late)",
            data: [1, 2, 2, 3, 1],
            stack: 2,
            color: '#000000'
        },{
            name: "Internet Problem (late)",
            data: [2, 1, 3, 2, 1],
            stack: 3,
            color: '#000000'
        },{
            name: "Software (late)",
            data: [3, 1, 2, 3, 1],
            stack: 4,
            color: '#000000'
        },{
            name: "User Accounts (late)",
            data: [1, 1, 2, 1, 1],
            stack: 5,
            color: '#000000'
        },{
            name: "Forms",
            data: [3, 5, 2, 8, 3],
            stack: 0
        },{
            name: "Gate Pass",
            data: [5, 7, 2, 4, 3],
            stack: 1
        },{
            name: "Hardware",
            data: [2, 5, 1, 3, 2],
            stack: 2
        },{
            name: "Internet Problem",
            data: [2, 3, 4, 1, 4],
            stack: 3
        },{
            name: "Software",
            data: [1, 3, 7, 5, 1],
            stack: 4
        },{
            name: "User Accounts",
            data: [2, 6, 4, 3, 5],
            stack: 5
        },{
                type: 'spline',
                name: '95% Goal',
                data: [
                    3, 4, 2, 4, 4,  6,
                    1, 6, 3, 2, 3,  3,
                    1, 2, 3, 2, 3,  3,
                    1, 2, 3, 2, 3,  3,
                    1, 2, 3, 2, 3,  3,    
                    ],
                xAxis: 1,
                marker: {
                    lineWidth: 2,
                    lineColor: Highcharts.getOptions().colors[1],
                    fillColor: '#FFFF00'                    
                }
            }];

Chart 1: Combining Spline with Grouped Columns

Source Code
(function () {
var chart = new Highcharts.Chart({

        chart: {
            type: 'column',
            renderTo: 'highchart-container',
        },

        title: {
            text: 'Total tickets closed, grouped by category'
        },

        xAxis: [{
            categories: ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5']
        },{
            categories:[
				'n1','n2','n3','n4','n5','n6', 
                'e1','e2','e3','e4','e5','e6',
                'w1','w2','w3','w4','w5','w6', 
                'x1','x2','x3','x4','x5','x6', 
                's1','s2','s3','s4','s5','s6'     
                ],
            opposite:true
        }],

        yAxis: {
            allowDecimals: false,
            min: 0,
            title: {
                text: 'Number of tickets'
            }
        }
        plotOptions: {
            column: {
                stacking: 'normal'
            }
        },
        
        legend: {
            layout: 'vertical',
            align: 'right',
            verticalAlign: 'top',
            borderWidth: 0,
            title:{
                text: 'Category<br /><span style="font-size: 9px;color: #666;font-weight: normal">(Click to hide)</span>'  
            }
        },
        series: nseries
    });
})();

One answer in that stackoverflow link mentions something like adjusting “groupPadding” to zero (0)

        plotOptions: {
            column: {
                groupPadding:0
            }
        }

Chart 2: With “groupPadding:0″

Looking at Chart 2, the visual presentation is already affected. That’s the reason why I don’t like playing with “groupPadding”. I don’t want to have a cluttered chart. You should agree with me that it is an eyesore. I want to keep the padding between the weeks.

That’s why here it is. Though its not yet complete but I will keep an update on my thought process here.

Let’s create the plugin

Getting started

Before we tackle the plugin design, let me start first by cleaning out the chart, removing the additional ticks, setting “tickLength” to zero, then, removing the previous setting with “groupPadding”.

        xAxis: [{
            categories: ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5']
        },{
            ...
            tickLength: 0
            ...
       }

Next, cleaning up the Legend for duplicates and centering it.

        legend: {
            ...
            x: 0,
            y: 100,
            ...
            labelFormatter: function(){
                var rg = new RegExp("late");   
                if(!rg.test(this.name)){
                    return this.name;
                }
                
            }
        },
Chart 3: Cleaned Chart

Wrapping the Plugin via Closure

(function(H){
    var each = H.each,
    pick = H.pick;
    ...
    //plugin contents here
    ...
}(Highcharts));

Inside the closure, we start by assigning variables to commonly used functions available inside the “Highcharts” namespace.

Hooking via Prototype Functions

Highcharts has a powerful utility function, Highcharts.wrap() which accepts the parent object as the first argument, followed by the name of the function to extend and replacement function as callback.

To be able to have our plugin execute before the spline will be drawn on the graph, we need to hook to “drawGraph” method of Highcharts.seriesTypes.spline.prototype.

H.wrap(H.seriesTypes.spline.prototype, "drawGraph", function(proceed){
        var series = this,
            options = series.options;

        ...
        //do something "before" drawGraph() is called.
        ...
        proceed.apply(this, Array.prototype.slice.call(arguments, 1)); 
        ...
        //do something "after" drawGraph() is called.
        ...
});

It is important to note that since some functions might return a value or object, we should therefore not forget to include the return statement as follows:

        //do something "before" drawGraph() is called.
        ...
        return proceed.apply(this, Array.prototype.slice.call(arguments, 1)); 
        ...
        //do something "after" drawGraph() is called.
        ...

Preventing the plugin to run unintentionally

It is highly likely that we would have several charts in a single page but not all uses “Spline combined with Grouped Column”. It would be a waste of time having our plugin run on all the existing charts in a page. To fix that, we need the user of our plugin to explicitly set an option variable to true when creating the chart.

H.wrap(H.seriesTypes.spline.prototype, "drawGraph", function(proceed){
        var series = this,
            options = series.options;

        if(options.fixRdbSpline){
            //do something only if the user intended it to
        }
        return proceed.apply(this, Array.prototype.slice.call(arguments, 1)); 
});

When our plugin will be used, the user can simply add “fixRdbSpline: true” to the options

           series:[{
                   ...
                },{
                   type: 'spline',
                   data:[...],
                   ...
                   fixRdbSpline: true
                }]

Count the number of visible columns

Our spline should react whenever the columns change from visible to hidden and vice versa by hiding/showing spline points on each respective columns that it corresponds to. We need to take into account also that the columns might be stacked thereby reducing the number of total columns.

            each(series.chart.series, function (otherSeries) {
                var otherOptions = otherSeries.options,
                    otherYAxis = otherSeries.yAxis;
                    
                if(otherSeries.type === "column" &amp;&amp; otherSeries.visible &amp;&amp; yAxis.len === otherYAxis.len &amp;&amp; yAxis.pos === otherYAxis.pos) {
                    ...
                    if(otherOptions.stacking) {
                        stackKey = otherSeries.stackKey;
                        
                        if(stackGroups[stackKey] === undefined) {
                            stackGroups[stackKey] = columnCount++;
                            ...
                        }
                        columnIndex = stackGroups[stackKey];
                    } else if(otherOptions.grouping !== false) {
                        columnIndex = columnCount++;
                        ...
                    }
                    otherSeries.columnIndex = columnIndex;        
                }           
            });

Capture a bar sample

We need to store a single instance of the bar so we can get its translated width on the graph

                if(otherSeries.type === "column" ...
                    if(barSampler == null){
                        barSampler = otherSeries;
                    }
                    ...

Store all the offset per category column

                        ...
                        if(stackGroups[stackKey] === undefined) {
                            stackGroups[stackKey] = columnCount++;
                            pointXOffsets.push(otherSeries.pointXOffset);
                        }
                        ...

Retrieve the column category width

From the captured bar sample, we get our column category width

            if(barSampler == null){
                series.hide();
            }
            else{
                xAxis = barSampler.xAxis; 
                yAxis = barSampler.yAxis;
                var categoryWidth = Math.min(
                        Math.abs(xAxis.transA) * (xAxis.ordinalSlope || options.pointRange || xAxis.closestPointRange || xAxis.tickInterval || 1), 
                        xAxis.len
                    );
                ...
            }

To Do:

  • When hiding/showing a category and as the column hides/shows, the spline point should be hidden or displayed back. – Update: Hide Spline Point is already functioning, but Show Spline Point still has a bug.
  • Fix wrong behavior during mouse hover where it does not select the right point in the spline as new points are already out of bounds with its original column width.

Finish Product

Plugin’s Source Code

(function(H){
    var each = H.each,
    pick = H.pick,
    origData = [];
    
    H.wrap(H.seriesTypes.spline.prototype, "drawGraph", function(proceed){
        var series = this,
            options = series.options,
            xAxis,
            yAxis = series.yAxis,
            reversedXAxis,
            stackKey,
            stackGroups = {},
            columnIndex,
            columnCount = 0,
            ps = this.data,
            chart = this.chart,
            barW = 0,
            barSampler = null,
            pointXOffsets = [],
            catCount = 0,
            startX = 0,
            whichOffset = 0,
            countColVisible = 0;

        if(options.fixRdbSpline){
            each(series.chart.series, function (otherSeries) {
                var otherOptions = otherSeries.options,
                    otherYAxis = otherSeries.yAxis;
                    
                if(otherSeries.type === "column" &amp;&amp; otherSeries.visible &amp;&amp;
                        yAxis.len === otherYAxis.len &amp;&amp; yAxis.pos === otherYAxis.pos) {
                    if(barSampler == null){
                        barSampler = otherSeries;
                    }
                    catCount++;
                    if(otherOptions.stacking) {
                        stackKey = otherSeries.stackKey;
                        
                        if(stackGroups[stackKey] === undefined) {
                            stackGroups[stackKey] = columnCount++;
                            pointXOffsets.push(otherSeries.pointXOffset);
                        }
                        columnIndex = stackGroups[stackKey];
                    } else if(otherOptions.grouping !== false) {
                        columnIndex = columnCount++;
                        pointXOffsets.push(otherSeries.pointXOffset);
                    }
                    otherSeries.columnIndex = columnIndex;        
                }           
            });

            

            if(barSampler != null){
                xAxis = barSampler.xAxis; 
                yAxis = barSampler.yAxis;
                var categoryWidth = Math.min(
                        Math.abs(xAxis.transA) * (xAxis.ordinalSlope || options.pointRange || xAxis.closestPointRange || xAxis.tickInterval || 1), // #2610
                        xAxis.len
                    );
                var mid = categoryWidth/2; 
                barW = barSampler.barW;
                var barMid = barW/2;    
            }
            
            if(barSampler == null){
                series.hide();
            }
            else{
                H.each(ps, function(p, i){
                    if(i % columnCount === 0)
                        startX = categoryWidth * (i/columnCount);
                    whichOffset = i % pointXOffsets.length;
                    p.plotX = startX + mid + pointXOffsets[whichOffset] + barMid;
                });
            }
        }

        proceed.apply(this, Array.prototype.slice.call(arguments, 1)); 
    });
    
    each(["hide","show"], function(i){
        H.wrap(H.Series.prototype,i, function(proceed){
            var series = this,
                chart  = this.chart,
                colIdx = 0,
                yAxis = series.yAxis,
                stackGroups = [],
                columnIndex,
                catCount = 0,
                columnCount = 0;
            
            each(chart.series, function(s,j){
                
                var otherOptions = s.options,
                otherYAxis = s.yAxis;
                
                if(s.type === "column" &amp;&amp; s.visible &amp;&amp;
                        yAxis.len === otherYAxis.len &amp;&amp; yAxis.pos === otherYAxis.pos) {
                    catCount++;
                    if(otherOptions.stacking) {
                        stackKey = s.stackKey;
                        
                        if(stackGroups[stackKey] === undefined) {
                            stackGroups[stackKey] = columnCount++;
                        }
                        columnIndex = stackGroups[stackKey];
                    } else if(otherOptions.grouping !== false) {
                        columnIndex = columnCount++;
                    }
                    s.columnIndex = columnIndex;        
                }
            });
            colIdx = this.columnIndex;
            console.log("columnIndex: "+colIdx);
            console.log("columnCount: "+columnCount);
            
            each(chart.series, function(s,j){
                if(s.type === "spline"){
                    if(origData.length === 0){
                        origData = s.data.map(function(p){
                            return p.y;    
                        });
                        console.log(origData);   
                    }
                    if(i == "hide"){
                        var d2RemoveNum = s.data.length / columnCount;
                        for(var ii=0; ii&lt; d2RemoveNum; ii++){
                            var ndx = (columnCount * ii) + colIdx - ii; 
                            s.data[ndx].remove(false,false);
                        }
                    }else{
                        var diffLen = origData.length - s.data.length;
                        var d2AddNum = s.data.length / columnCount;
                        
                        console.log(&quot;columnIndex: &quot;+colIdx);
                        console.log(&quot;columnCount: &quot;+columnCount);
                        console.log(&quot;s.data.length: &quot;+s.data.length);
                        
                        
                        console.log(&quot;sdiff: &quot;+diffLen);
                        console.log(&quot;catCount: &quot;+catCount);
                        
                        console.log(&quot;d2AddNum: &quot;+d2AddNum);
                        
                        for(var ii=0; ii&lt; d2AddNum; ii++){
                            var ndx = ((columnCount+1) * ii) + colIdx; 
                            console.log(&quot;ndx: &quot;+ndx+&quot; ndx2: &quot;+(ndx + ii)+&quot; o: &quot;+origData[ndx]);
                            s.addPoint([ndx, origData[ndx]],false,false);
                        }
                        
                    }   
                }
            });
            
            console.log(series);
            return proceed.apply(this, Array.prototype.slice.call(arguments, 1));
        });    
    });
}(Highcharts));