Highcharts custom renderer chart and tooltip

user2285607 picture user2285607 · Nov 15, 2013 · Viewed 11.9k times · Source

Our idea was to draw a waterfall chart with unregular widths. We achieved this chart style by rendering rectangulars to the corresponding data points (visible in fiddle for demonstration purposes).
Moreover, we want to add a tooltip and make this tooltip following the mouse.

We are facing three problems:

  1. When you zoom very close to the chart, you will see that namely rect2 and rect3 as well as rect3 and rect4 show small gaps between the rectangulars edges. This seems to be strange because all rectangulars have been created by the same for-loop procedure (lines 68-84 in fiddle). Any ideas? (If you change the chart width, the gaps can vanish or occur between other rectangulars...)

  2. For the first and last rectangular we wanted to create individual borders. Therefore, we set up white borders to the first and last rectangulars (lines 97,155 in fiddle) and added our renderer paths (dotted, solid) lines afterwards (lines 221-298). As you can see in case of rect0 the vertical line does not cover the white border exactly, even though we used the same plot coordinates as the rectangular has. (If you change the chart width, the magnitude of the problem gets better or even worse)

  3. We rendered custom tooltips to the renderer group (rectangulars, dataLabels) and display these by mouseover and mouseout events. A first problem was that the tooltip disappeared when hovering the dataLabel. We made a workaround (lines 190-195) but we are wondering if there is a more elegant way to display the tooltip on both rects and labels. Further, we want to make the tooltip follow the mouse movements (event mousemove) but we can't get this event to work on our example.

Here is our fiddle example

$(function () {


var chart = new Highcharts.Chart({
    chart: {
        renderTo: 'container',
        type: 'scatter'
    },
    title: {
        text: 'Custom waterfall with unequal width'
    },
    xAxis: {
        min: 0,
        max: 50,
        reversed: true
    },
    yAxis: {
        title: {
            text: 'Share'
        },
        min: 0,
        max: 100
    },
    tooltip: {
        enabled: false
    },
    legend: {
        enabled: false
    },
    credits: {
        enabled: false
    },
    series: [{
        name: 'basicData',
        visible: true, //for demonstration purpose
        data: [
            [50, 40],
            [45, 48],
            [39, 52],
            [33, 68],
            [22, 75],
            [15, 89],
            [5, 100]
        ]
    }]
},
//add function for custom renderer
function (chart) {

    var points = this.series[0].data,
        addMarginX = this.plotLeft,
        addMarginY = this.plotTop,
        xZero = this.series[0].points[0].plotX,
        yZero = this.chartHeight - addMarginY - this.yAxis[0].bottom,
        xAll = [],
        yAll = [],
        widthAll = [],
        heightAll = [];

    //renderer group for all rectangulars
    rectGroup = chart.renderer.g()
        .attr({
        zIndex: 5
    })
        .add();

    //draw for each point a rectangular
    for (var i = 0; i < points.length; i++) {

        var x = points[i].plotX + addMarginX,
            y = points[i].plotY + addMarginY,
            width,
            height;

        if (i === 0) { //for the first rect height is defined by pixel difference of yAxis and yValue
            height = yZero - points[i].plotY
        } else { // else height is pixel difference of yValue and preceeding yValue
            height = points[i - 1].plotY - points[i].plotY
        };
        if (i === points.length - 1) { // for the last rectangular pixel difference of xValue and xAxis at point=0
            width = this.xAxis[0].translate(0) - points[i].plotX
        } else { // else pixel difference of xValue and subsequent xValue
            width = points[i + 1].plotX - points[i].plotX
        };

        xAll.push(x);
        yAll.push(y);
        widthAll.push(width);
        heightAll.push(height);

        //general styling of rects, exception for first and last rect
        var attrOptions;
        if (i === 0) {
            attrOptions = {
                id: i,
                    'stroke-width': 0.75,
                stroke: 'rgb(255, 255, 255)', //white border which is later covered by dotted lines
                fill: {
                    linearGradient: {
                        x1: 1,
                        y1: 0,
                        x2: 0,
                        y2: 0
                    },
                    stops: [
                        [0, Highcharts.getOptions().colors[0]],
                        [1, 'rgba(255,255,255,0.5)']
                    ]
                }
            };
        } else if (i === points.length - 1) {
            attrOptions = {
                id: i,
                    'stroke-width': 0.75,
                stroke: 'rgb(255, 255, 255)', //white border which is later covered by dotted lines
                fill: {
                    linearGradient: {
                        x1: 0,
                        y1: 0,
                        x2: 1,
                        y2: 0
                    },
                    stops: [
                        [0, Highcharts.getOptions().colors[0]],
                        [1, 'rgba(255,255,255,0.5)']
                    ]
                }
            };
        } else {
            attrOptions = {
                id: i,
                    'stroke-width': 0.75,
                stroke: 'black',
                fill: Highcharts.getOptions().colors[0]
            };
        }

        // draw rect, y-position is set to yAxis for animation
        var tempRect = chart.renderer.rect(x, this.chartHeight - this.yAxis[0].bottom, width, 0, 0)
            .attr(attrOptions)
            .add(rectGroup);

        //animate rect
        tempRect.animate({
            y: y,
            height: height

        }, {
            duration: 1000
        });
    }; // for loop ends over all rect


    //renderer centered dataLabels to rectangulars
    for (var i = 0; i < points.length; i++) {

        var labelColor = 'rgb(255,255,255)';
        if (i === 0 || i === points.length - 1) {
            labelColor = '#666666'
        }
        var label = chart.renderer.label('rect' + i)
            .attr({
            align: 'center',
            zIndex: 5,
            padding: 0
        })
            .css({
            fontSize: '11px',
            color: labelColor
        })
            .add(rectGroup);

        var labelBBox = label.getBBox();

        label.attr({
            x: xAll[i] + widthAll[i] * 0.5,
            y: yAll[i] + heightAll[i] * 0.5 - labelBBox.height * 0.5
        });
    }; // loop for dataLabels ends


    // add tooltip to rectangulars AND labels (rectGroup)
    var tooltipIndex;

    rectGroup.on('mouseover', function (e) {

        //get the active element (or is there a simpler way?)
        var el = (e.target.correspondingUseElement) ? e.target.correspondingUseElement : e.target;

        //determine with the 'id' to which dataPoint this element belongs
        //problem: if label is hovered, use tootltipIndex of rect
        var i = parseFloat(el.getAttribute('id'));
        if (!isNaN(i)) {
            tooltipIndex = i;
        }
        // render text for tooltip based on coordinates of rect
        text = chart.renderer.text('This could be <br>an informative text', xAll[tooltipIndex], yAll[tooltipIndex] - 30)
            .attr({
            zIndex: 101
        })
            .add();

        var box = text.getBBox();
        //box surrounding the tool tip text                     
        border = chart.renderer.rect(box.x - 5, box.y - 5, box.width + 10, box.height + 10, 5)
            .attr({
            fill: 'rgba(255, 255, 255, 0.95)',
            stroke: 'blue',
                'stroke-width': 1,
            zIndex: 100
        })
            .add();
    })
        .on('mouseout', function () {

        text.destroy();
        border.destroy();
    })


    //render first and last rect as open and partly dotted rect
    var M = 'M',
        L = 'L',
        pathStartSol = [],
        pathEndSol = [],
        pathStartDot = [],
        pathEndDot = [],
        y0 = this.chartHeight - this.yAxis[0].bottom,
        last = xAll.length - 1;

    pathStartDot = [
    M, xAll[0], y0,
    L, xAll[0] + widthAll[0] * 0.6, y0,
    M, xAll[0], y0,
    L, xAll[0] + widthAll[0] * 0.6, y0,
    M, xAll[last] + widthAll[last] * 0.4, y0,
    L, xAll[last] + widthAll[last], y0,
    M, xAll[last] + widthAll[last] * 0.4, y0,
    L, xAll[last] + widthAll[last], y0];

    pathStartSol = [
    M, xAll[0] + widthAll[0] * 0.6, y0,
    L, xAll[1], y0,
    L, xAll[1], y0,
    L, xAll[0] + widthAll[0] * 0.6, y0,
    M, xAll[last] + widthAll[last] * 0.4, y0,
    L, xAll[last], y0,
    L, xAll[last], y0,
    L, xAll[last] + widthAll[last] * 0.4, y0];

    pathEndDot = [
    M, xAll[0], yAll[0],
    L, xAll[0] + widthAll[0] * 0.6, yAll[0],
    M, xAll[0], y0,
    L, xAll[0] + widthAll[0] * 0.6, y0,
    M, xAll[last] + widthAll[last] * 0.4, yAll[last],
    L, xAll[last] + widthAll[last], yAll[last],
    M, xAll[last] + widthAll[last] * 0.4, yAll[last - 1],
    L, xAll[last] + widthAll[last], yAll[last - 1]];

    pathEndSol = [
    M, xAll[0] + widthAll[0] * 0.6, yAll[0],
    L, xAll[1], yAll[0], // does not match exactly the underlying white border of rect
    L, xAll[1], y0, // does not match exactly the underlying white border of rect
    L, xAll[0] + widthAll[0] * 0.6, y0,
    M, xAll[last] + widthAll[last] * 0.4, yAll[last],
    L, xAll[last], yAll[last],
    L, xAll[last], yAll[last - 1],
    L, xAll[last] + widthAll[last] * 0.4, yAll[last - 1]];

    var pathSol = chart.renderer.path(pathStartSol)
        .attr({
        'stroke-width': 1,
        stroke: 'black',
        zIndex: 100
    }).add();

    var pathDot = chart.renderer.path(pathStartDot)
        .attr({
        'stroke-width': 1,
        stroke: 'black',
        zIndex: 100,
        dashstyle: 'Dot'
    }).add();

    pathSol.animate({
        d: pathEndSol
    }, {
        duration: 1000
    });

    pathDot.animate({
        d: pathEndDot
    }, {
        duration: 1000
    });

});

});

We know it's a rather complex example but would appreciate all ideas that come up to you guys. Thanks!

Answer

user2285607 picture user2285607 · Dec 3, 2013

Now we have a working version (thx Pawel!!!):

  1. problem: some rectangulars are not connected; solution: all plotX and plotY coordinates have to be rounded before you do calculations with them.

  2. problem: missmatch of individual borders and rectangulars; solution: again rounding did the trick

  3. problem: a) mousemove for custom rendered tooltip b) bind tooltip on hover event for label and rectangular; solution: a) reject custom tooltip idea instead bind highcharts tooltip of corresponding data point on hover event for rectangular b) create a ghost (completely transparent) for each rectangular and bind hover event on it

    //draw ghost for each rectangular and bind tooltip of highcharts on it
    for (var i = 0; i < points.length; i++) {
    
        var ghostRect = chart.renderer.rect(xAll[i], yAll[i], widthAll[i], heightAll[i], 0)
            .attr({
            id: i,
            'stroke-width': 0,
            stroke: 'rgba(255, 255, 255, 0)',
            fill: 'rgba(255, 255, 255, 0)',
            zIndex: 10
        })
            .add()
            .on('mouseover', function () {
                 var index = parseInt(this.getAttribute('id'));
                 var point = chart.series[0].points[index];
                 chart.tooltip.refresh(point);
        })
            .on('mouseout', function () {
                 chart.tooltip.hide();
        });
    
    
    };
    

    Here is the working fiddle