Power BI Custom Visual Part 2 – Creating a Tree Funnel Chart

Power BI Visuals basically render a bunch of HTML to display a chart. Typically these will be SVG elements and most likely use the D3 library to help draw them. So when I started on developing a Power BI Custom Visual it made sense to develop the chart rendering separately and worry about how to hook it into the Power BI Visual after I was happy with the basic chart logic. This section then is purely D3.


The complete code used in this section is available on GitHub.


The chart I’m going to develop is a Tree Funnel. A funnel chart shows a measure at different stages of a process leading to a desired goal – for example how many leads are moved through each stage of a sales process until a sale is made.

A traditional funnel visual

My tree funnel will do something similar but allows the final goal to be segmented into multiple outcomes and focuses more on how prospects are lost during the process. For example, if we are investigating undergraduate students completing their degree we are interested in why some did not complete as intended and the level of award achieved for those that did. The tree funnel could show this as:

Power BI tree funnel custom visual

Here the total number of students enrolled on the course is shown by the height of the trunk. Each branch shows the number of students failing to achieve their intended award and each leaf shows the segmentation of the desired outcome – here the level of award achieved.

The data required to populate the chart is simple – a single category to define the branches and leaves and a single measure. Later I will add an additional category to sort by but I’ll do this when setting up the Power BI Visual. For now this is the data I’ll use:

var dataSource = [
{ name: "Withdrawn", value: 15},
{ name: "Transfered", value: 10},
{ name: "Certificate", value: 5},
{ name: "Diploma", value: 5},
{ name: "1st", value: 20},
{ name: "2.1", value: 20},
{ name: "2.2", value: 20}
];

First I’ll set up the basic dimensions of the chart. The width of the chart will be split into the width of the trunk portion (b) and the leaves portion (c). There will also be two vertical axes so the total chart width will be a + b + c + d. The height of the trunk (f) and the height of the leaves (g) will be defined as percentages of the chart height (h). There will be one horizontal axis so the total chart height will be e + h.

Tree funnel dimensions

Setting up the dimensions will be the first part of the JavaScript to create the chart. When I move this to Power BI the viewport dimensions, axis sizing and trunk height and width, and leaves height will all be set by the user but for now they’re hardcoded. All other dimensions are derived from these values:

// Set up the dimensions
var viewPortWidth = 1200
var viewPortHeight = 300
var yAxisWidth = 50;
var yLeavesAxisWidth = 50;
var xAxisHeight = 25;
// Calculate the chart size without axes
var width = viewPortWidth - (yAxisWidth + yLeavesAxisWidth);
var height = viewPortHeight - xAxisHeight;
// Size of trunk height and width and leaves height as percentage of chart size
var trunkHeightPercent = .4;
var trunkWidthPercent = .8;
var leavesHeightPercent = .8;
// Branch spacing
var branchSize = 1;
// Calculate trunk height and width and leaves width
var trunkHeight = height * trunkHeightPercent;
var trunkWidth = width * trunkWidthPercent;
var leaveWidth = width - trunkWidth;
// Calculate the range for the leaves Y scale
var leavesRangeStart = ((height / 2) * (1 - leavesHeightPercent));
var leavesRangeEnd = height - leavesRangeStart;
// Calculate the position of the top of the trunk
var trunkTop = (height / 2) - (trunkHeight / 2);

Next I will process the data in the dataSource array generating a new data array which adds in columns.

  • runSum – the running sum of array values
  • leafRunSum – the running sum of the leaf rows only
  • end – bool indicating if the row is a leaf row

And calculates the total value of all rows (trunkDomain) and the total value of all leaf rows (leavesDomain). And creates arrays of all branch row names (branchCategoryValues) and leaf row names (leafCategoryValues).

There is a single variable leaves which specifies the number of rows that are leaves – this number of rows at the end of the dataSource are mapped as leaves. When in Power BI the user will specify this number but for now it’s hard coded. Here’s the data processing code.

// Convert the incoming data into a new data set which adds
// runSum, leafRunSum and end properties
var data = [];
// Leaves defines the number of records that are plotted as leaves. The last X records will in the source
// will be treated as leaves.
var leaves = 3;
var items = dataSource.length;
var branches = items - leaves;
// Leaves domain is the total value of the leaves. TrunkDomain is the total value.
var leavesDomain = 0;
var trunkDomain = 0;
// Branch and leaf category arrays will be used to populate the ordinal axis domains
var branchCategoryValues = [];
var leafCategoryValues = [];
// A running sum of all values and leaves will be added to the data. And a flag indicating the row is a leaf value
var runSum = 0;
var leafRunSum = 0;
var isLeaf = false;


// Process the data array
dataSource.forEach((item, index) => {

trunkDomain += item.value;

// If the row is a leaf
if (index > (branches - 1)) {

leavesDomain += (item.value);
leafCategoryValues.push(item.name);
isLeaf = true;

}
// If the row is branch
else {

branchCategoryValues.push(item.name);

}


data.push({ name: item.name, value: item.value, runSum: runSum, leafRunSum: leafRunSum, end: isLeaf });

runSum += item.value;
if (index > (branches - 1)) {
leafRunSum += item.value;
}


})

Now I have all the data I need to create the chart. Next I’ll set up some scales. First a scale to map the measure values to the height of the trunk. The domain is 0 to the sum of all rows and the range is 0 to the height of the trunk. This scale will be used to set the width of each branch or leaf, position it within the trunk and to draw the left Y axis:

        // Y scale maps the values to a portion of the trunk height
        var yScale = d3.scale.linear()
            .domain([0, trunkDomain])
            .range([0, trunkHeight]);

Next a scale to map each leaf to a height within the leaves section of the chart. The rows will be positioned evenly across this range so an ordinal scale will work for this. The domain will be the category names for each leaf row and the range is the start and end heights of leaves section. This scale will be used to position the height of the end of each leaf and to draw the right Y axis:

        // Y scale leaves maps the leaf category values to a position on the leaves height
        var yScaleLeavesOrdinal = d3.scale.ordinal()
            .domain(leafCategoryValues)
            .rangePoints([leavesRangeStart, leavesRangeEnd]);

Then a scale to map each branch category to its end width position along the trunk width. Like the yScaleLeavesOrdinal this will evenly space each branch across the trunk width but because some additional space is required to accommodate the start of the trunk and the curve of the branch I’ve used a linear scale where the domain is 0 to number of branches + 1 and the range starts one branch width in. Because this is a linear scale an additional ordinal scale is required to draw the top axis:

        // X scale maps the branch categories by position to a position on the trunk width

        var xScale = d3.scale.linear()
            .domain([0, branches+1])
            .range([trunkWidth / (branches + 1) , trunkWidth]);

        // X scale ordinal maps the branch categories to a postion on the trunk width
        var xScaleOrdinal = d3.scale.ordinal()
            .domain(branchCategoryValues)
            .rangePoints([xScale(branchSize), xScale((branches-1)+branchSize)]);

Finally a linear scale that maps each leaf and branch to a colour. Here I’ve hardcoded a range between yellow and red but in Power BI this will be set by the user:

        // Colour scale maps each category to a colour within the specified range
        var colourScale = d3.scale.linear()
            .domain([0, items])
            .range(['yellow', 'red']);

Next I set up the three axes:

        //create left axis
        var yAxis = d3.svg.axis()
            .scale(yScale)
            .orient("left");

        //create top axis
        var xAxisOrdinal = d3.svg.axis()
            .scale(xScaleOrdinal)
            .orient("top");

        //create right axis
        var yAxisLeavesOrdinal = d3.svg.axis()
            .scale(yScaleLeavesOrdinal)
            .orient("right");

And now I can start drawing each line. A row of data will be a line on the chart. Each line will be a path with a stroke width proportionate to the size of the row’s measure. Each branch will be composed of a horizontal line of length proportional to the number of the row followed by a Bezier curve extending one branch width and that ends at the top of chart. Both control points will be 1 branch width along the horizontal – like this:

Branch bezier

Leaves will have a horizontal the same length as the longest branch followed by a Bezier where the end point is at the end of chart and each row is evenly distributed along the height of the leaves height. The first control point will be on the horizontal height and halfway along the width between the curve start and end and the second will be at the same width and the height of the endpoint:

Leaf bezier

To do all that I’ll define a function getLine that given a data point will return a path. Notice that the initial height (trunkPos) is the yScale value of the total of all previous rows and half the current row. This means everything will fit together nicely when the stroke width of the line is set based on the row’s value. This value then needs to be shifted down to the top of the trunk (trunkTop). 

        // function to generate the path for each value
        var getLine =
            function (d, i) {

                // Get the mid point position on the yScale for this value.
                // Add in trunk top to position it below the start of the trunk top.
                var trunkPos = yScale(d.runSum + (d.value / 2)) + trunkTop;
                var moveToTop = "M 0, " + trunkPos + " ";
                
                // If the value is a branch
                if (!d.end) {

                    // Get the X position of the end of the line from the xScale and generate horizontal
                    var horizontalLine = "H " + xScale(i) + " ";
                    // Get the position of the control points from the xScale and generate a bezier
                    var control1 = xScale(i + branchSize) + "," + trunkPos + " ";
                    var control2 = xScale(i + branchSize) + "," + trunkPos + " ";
                    var endPoint = xScale(i + branchSize )  + "," + 0 + " ";

                }

                // for outcome values
                else {

                    // Get the position of the end of the line from the xScale and generate horizontal
                    var horizontalLine = "H " + xScale(branches) + " ";
                    // Get the Y position of the leaf end point
                    var yPosition = yScaleLeavesOrdinal(d.name);
                    // Generate the bezier 
                    var control1 = (trunkWidth + (leaveWidth / 2)) + "," + trunkPos + " ";
                    var control2 = (trunkWidth + (leaveWidth / 2) ) + "," + yPosition + " ";
                    var endPoint = (width) + "," + yPosition + " ";

                    }

                return moveToTop + horizontalLine + "C " + control1 + control2 + endPoint;

        }


And that’s all the hard stuff done. Now it’s just a matter of adding the elements. First a container to hold the chart (without axes) and translate that to the right and below where the left and top axes will be:

        // Select the chart object
        var chart = d3.select(".chart");

        // Add a container to hold the chart and move it below and right of the axis
        var container = chart.append("g")
            .attr("transform", "translate(" + yAxisWidth + ", " + xAxisHeight + ")");


Then add an element for each line:

        // Add an element for each data point
        var line = container.selectAll("g")
            .data(data)
            .enter()
            .append("g");

And set the lines attributes. The stroke colour will be derived from colour Scale using the index of the row and the stroke width from the yScale using the row value. The path is calculated by the getLine function discussed above:

        // Set the path options and add the whole line

        line.append("path")
            .attr("stroke", function (d,i) {return colourScale(i)})
            .attr("stroke-width", function (d) { return yScale(d.value) })
            .attr("fill", "none")
            .attr("shape-rendering", "geometricPrecision")
            .attr("d", getLine);

Finally add in the axes and translate them to their correct positions:

        // Add axes and translate them into place

        chart.append("g")
            .call(yAxis)
            .attr("class", "axis")
            .attr("transform", "translate(" + (yAxisWidth -10) + ", " + (xAxisHeight + trunkTop) + ")");

        chart.append("g")
            .call(xAxisOrdinal)
            .attr("class", "axis")
            .attr("transform", "translate(" + (yAxisWidth) + ", " + (xAxisHeight - 5) + ")");

        chart.append("g")
            .call(yAxisLeavesOrdinal)
            .attr("class", "axis")
            .attr("transform", "translate(" + (yAxisWidth + width + 5) + ", " + (xAxisHeight) + ")");

And that’s that. The HTML element the script selects is below. Except… one issue that I found with the rendering of the SVG. In order not to bloat this section too much I’ve detailed it separately. The next section explains how set up a custom Power BI visual and replicate this chart logic there.

<!DOCTYPE html>

<html>
<head>
    <meta charset="utf-8" />
    <script src="https://d3js.org/d3.v3.min.js" charset="utf-8"></script>

    <title></title>
    <style>

        .axis path,
        .axis line {
            fill: none;
            stroke: #000;
            shape-rendering: crispEdges;
            }

        .axis text {
            font: 9px sans-serif;
            fill: black;
        }

    </style>
</head>
<body>

    <svg class="chart" width="1200" height="300">
            
    </svg>



</body>
</html>