User Flow with dropouts using D3 Sankey in Angular 10
Idris Rampurawala
Posted on December 17, 2020
š 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';
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 attributedrop
ā 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 withname
asDropout
anddrop
as0
. 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
},
...
]
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>
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}`);
}
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;
}
})
;
}
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));
}
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);
}
š 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.
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
June 30, 2021