User Flow with dropouts using D3 Sankey in Angular 10

idrisrampurawala

Idris Rampurawala

Posted on December 17, 2020

User Flow with dropouts using D3 Sankey in Angular 10

šŸ“œ Background

A Sankey chart (diagram) is a visualization used to depict a flow from one set of values to another. The things being connected are called nodes and the connections are called links.

One of the use cases of the Sankey chart is to represent the user flow or user journey or screen flow. When it comes to user journey analysis, these diagrams allow identifying at a glance what are the most frequently fired events, in what order, or what are the different paths from action A to action B. These are information that marketers, decision-makers, or your customers are likely to be interested in. Specifically, when representing user journeys, the dropouts are a game-changer.

A dropout is basically a node representing the number of users that didn't go to the next screen/action but exited the application (user flow). There are a lot of open-source libraries available to create Sankey charts such as Google, Highcharts, D3, etc. But none of them provide a capability to put in a dropout node in the flow. I was able to accomplish this using D3's Sankey chart. Let's check out how I achieved this feat šŸ˜

āœ… Prerequisites

  • We will assume that you have a basic knowledge of the Angular framework and D3 Sankey chart
  • This post only aims to guide with the logic of implementation and hence showcasing only code snippets. For overall code implementation, check out my GitHub repository.

šŸ§± Integrating D3 with Angular

1. Install D3

D3 is hosted on npm so we can easily install it via the npm command
npm install d3

2. Install D3-sankey

To create Sankey charts in D3, we will have to add d3-sankey npm package on top of the D3
npm install d3-sankey

3. Import dependencies in Angular

In our app.component.ts, we will just import packages as

import * as d3 from 'd3';
import * as d3Sankey from 'd3-sankey';
Enter fullscreen mode Exit fullscreen mode

That's it! We are now ready to move to the next step šŸ˜

āš’ļø Implementing Dropouts in Sankey chart

A Sankey chart consists of two entities to generate the graph:

Node A rectangular box that represents the actual entity (i.e. in our example, it represents a screen user is visiting)
Link It connects two nodes based on their weight

1. Preparing data

  • The minimum attributes that are required to create a node are node (unique id), name. Here, we will also add one more attribute drop āž– a number representing the dropouts on this node.
  • Similarly, for links, the attributes are source, target, value.
  • To represent dropouts, we will make a node with name as Dropout and drop as 0. This node will not have any link that will result in two nodes (node and dropout node) placed adjacent to each other.

Our data structure would look like this:

'nodes': [
      {
        'node': 0, // unique node id
        'name': 'HomeActivity', // name of the node
        'drop': 2 // weight representing if any dropouts from this node
      },
      {
        'node': 1,
        'name': 'Dropout', // we always represent dropouts with this common name
        'drop': 0
      },
...
]
'links': [
      {
        'source': 0, // source node id
        'target': 1, // target node id
        'value': 2 // link weight
      },
      {
        'source': 0,
        'target': 2,
        'value': 2
      },
...
]
Enter fullscreen mode Exit fullscreen mode

2. Prepare HTML for rendering

Once we have data generated, it's time to add Sankey chart logic to generate the graph.

Let's consider we have a div for plotting sankey

<!-- app.component.html -->
 <div id="sankey"></div>
Enter fullscreen mode Exit fullscreen mode

3. Adding Sankey chart rendering logic

Secondly, let's add some initial Sankey chart rendering logic in the app.component.ts ngOnInit function which gets called on-page init

// app.component.ts
...

ngOnInit(): void {
  // get some dummy data created in above step
  const chartData = {
    'nodes': [
      {
        'node': 0, // unique node id
        'name': 'HomeActivity', // name of the node
        'drop': 2 
      },
      ...
    ],
    'links': [
      {
        'source': 0,
        'target': 1,
        'value': 2
      }
      ...
    ]
  };

  this.drawChart(chartData);
}
...

drawChart(chartData): void {
 // plotting the sankey chart
    const sankey = d3Sankey.sankey()
      .nodeWidth(15)
      .nodePadding(10)
      .nodeAlign(d3Sankey.sankeyLeft)
      .extent([[1, 1], [width, height]]);
    sankey(chartData);

    const iter = d3.nest()
      .key((d: any) => d.x0)
      .sortKeys(d3.ascending)
      .entries(chartData.nodes)
      .map((d: any) => d.key)
      .sort((a: any, b: any) => a - b);

    // add svg for graph
    const svg = d3.select('#sankey').append('svg')
      .attr('width', width)
      .attr('height', height)
      .attr('viewbox', `0 0 ${width} ${height}`);
}

Enter fullscreen mode Exit fullscreen mode

4. Adding Sankey chart links

Now, let's add links to the Sankey chart (in the same drawChart()). We are gonna exclude the links which end with the Dropout node i.e. the links that have target as the Dropout node. This will help us to create a dropout node adjacent to its source node without any link in between.

// app.component.ts
drawChart(chartData): void {
...
// add in the links (excluding the dropouts, coz it will become node)
    const link = svg.append('g')
      .selectAll('.link')
      .data(chartData.links)
      .enter()
      .filter((l: any) => l.target.name.toLowerCase() !== DROPOUT_NODE_NAME)
      .append('path')
      .attr('d', d3Sankey.sankeyLinkHorizontal()
      )
      .attr('fill', 'none')
      .attr('stroke', '#9e9e9e')
      .style('opacity', '0.7')
      .attr('stroke-width', (d: any) => Math.max(1, d.width))
      .attr('class', 'link')
      .sort((a: any, b: any) => {
        if (a.target.name.toLowerCase() === DROPOUT_NODE_NAME) {
          return -1;
        } else if (b.target.name.toLowerCase() === DROPOUT_NODE_NAME) {
          return 1;
        } else {
          return 0;
        }
      })
      ;

}
Enter fullscreen mode Exit fullscreen mode

5. Adding dropout nodes

Let's now plot the dropout nodes. This is the most important step as we plot the dropout nodes here. So how do we achieve this? Well, remember, we left the link which targets the dropout node in the above step? That's where we put in the dropout node (i.e. a rectangle in terms of D3).

The most important question is how to identify the height of this dropout node? šŸ˜¦ It's a little tricky question to solve. Remember, we are plotting dropout at the source node and hence we find the height of all the links on this node, excluding the dropout link (which we have not plotted). So, the dropout node height is
šŸ§ height of source node - the height of all non-dropout links of this node

// app.component.ts
drawChart(chartData): void {
...

    // plotting dropout nodes
    const dropLink = svg.append('g')
      .selectAll('.link')
      .data(chartData.links)
      .enter()
      .filter((l: any) => l.target.name.toLowerCase() === DROPOUT_NODE_NAME)
      .append('rect')
      .attr('x', (d: any) => d.source.x1)
      .attr('y', (d: any) => {
        if (d.source.drop > 0) {
          let totalWidth = 0;
          for (const elm of d.source.sourceLinks) {
            if (elm.target.name.toLowerCase() === DROPOUT_NODE_NAME) {
              break;
            } else if (elm.value >= d.source.drop && elm.target.name.toLowerCase() !== DROPOUT_NODE_NAME) {
              totalWidth += elm.width;
            }
          }
          return d.source.y0 + totalWidth;
        } else {
          return d.source.y0;
        }
      })
      .attr('height', (d: any) => Math.abs(d.target.y0 - d.target.y1))
      .attr('width', (d: any) => sankey.nodeWidth() + 3)
      .attr('fill', '#f44336')
      .attr('stroke', '#f44336')
      .attr('class', 'dropout-node')
      .on('click', (l: any) => {
        fnOnDropOutLinkClicked(l);
      });

    dropLink.append('title')
      .text((d: any) => d.source.name + '\n' +
        'Dropouts ' + format(d.value));

    // add the link titles
    link.append('title')
      .text((d: any) => d.source.name + ' ā†’ ' +
        d.target.name + '\n' + format(d.value));

}
Enter fullscreen mode Exit fullscreen mode

6. Finishing the chart by adding non-dropout nodes

Finally, let's add all the non-dropout nodes

// app.component.ts
drawChart(chartData): void {
...
    // plotting the nodes
    const node = svg.append('g').selectAll('.node')
      .data(chartData.nodes)
      .enter().append('g')
      .attr('class', 'node')
      .on('mouseover', fade(1))
      .on('mouseout', fade(0.7))
      .on('click', (d) => {
        fnOnNodeClicked(d);
      });

    node.append('rect')
      .filter((d: any) => d.name.toLowerCase() !== DROPOUT_NODE_NAME)
      .attr('x', (d: any) => d.x0)
      .attr('y', (d: any) => d.y0)
      .attr('height', (d: any) => d.y1 - d.y0)
      .attr('width', (d: any) => d.x1 - d.x0)
      .attr('fill', '#2196f3')
      .append('title')
      .text((d: any) => d.name + '\n' + format(d.value));

    node.append('text')
      .filter((d: any) => d.name.toLowerCase() !== DROPOUT_NODE_NAME)
      .attr('x', (d: any) => d.x1 + 20)
      .attr('y', (d: any) => (d.y1 + d.y0) / 2)
      .attr('dy', '0.35em')
      .attr('font-size', 10)
      .attr('font-family', 'Roboto')
      .attr('text-anchor', 'end')
      .text((d: any) => truncateText(d.name, 20))
      .attr('text-anchor', 'start')
      .append('title')
      .text((d: any) => d.name);

}
Enter fullscreen mode Exit fullscreen mode

šŸ† Voila! That's all we need to create a dropout node feature in Sankey chart šŸ˜Œ


āœ”ļø For more features such as, showing interaction levels, node click handler, dynamic data update, etc you can check my GitHub repository or visit this for a live demo.


If you like my post do not forget to hit ā¤ļø or šŸ¦„
See ya! until my next post šŸ˜‹
šŸ’– šŸ’Ŗ šŸ™… šŸš©
idrisrampurawala
Idris Rampurawala

Posted on December 17, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related