MP Logo

Tutorial - Multi-funnel network graph

Integrating a third-party data-visualization library (D3) with your reports

D3 is a data-visualization tool used for rendering your data in the browser using SVG. If you find you want to display data in a way not covered by one of the Mixpanel Platform charts, the combination of Mixpanel Platform and D3 will give you almost limitless flexibility. Check out the D3 homepage for examples and documentation.

We're going to use D3 to create the graph above, which turns data from multiple funnels into a network of events showing the paths users take around your app. This is an example of D3's force-directed layout, an organic approach to arranging nodes in a graph. We'll start with the blank-slate report, so create a new report and choose that as the template. Now you can choose to either use the Mixpanel report editor, or build the app locally following the instructions here - this tutorial will work equally well using either method.

In the code examples below, the line numbers will match up with the line numbers in the current state of the code at the end of each section. So if you want to refer to the full code file to get some context and see where things should go, find the link at the bottom of the current section - this will take you to the full file up to your current point in the tutorial.

The first thing we'll do is set up the basic UI that will let you add events and make funnel queries. Replace the contents of the body tag in your new report with this code:

  <body class="mixpanel-platform-body">
    <div class="mixpanel-platform-section">
      <div id="eventSelect"></div>
    </div>
    <script>
      var eventsArray = ['Signup'];

      var eventSelect = $('#eventSelect').MPEventSelect();

      var runQuery = function(events) {
        console.log('Making a funnel query with events:', events);

        // Call the funnel endpoint passing events
        // If events is ['A', 'B', 'C'], this is equivalent to calling
        // MP.api.funnel('A', 'B', 'C') ...
        MP.api.funnel.apply(MP.api, events).done(function(funnelData) {
          console.log('Results:');
          console.log(funnelData);
        });
      };

      // Do an initial query to render the first event node
      runQuery(eventsArray);

      // When the event select changes, re-query and re-render
      eventSelect.on('change', function() {
        var event = eventSelect.MPEventSelect('value');

        // Only add events that we aren't already querying on
        if (!_.contains(eventsArray, event)) {
          eventsArray.push(event); // add the new event to our events array
          runQuery(eventsArray);
        }
      });
    </script>
  </body>

We've added a few things here, including event and date selects, as well as the runQuery function. This is the core of our report - it uses an array of events to query the funnel api endpoint:

        // Call the funnel endpoint passing events
        // If events is ['A', 'B', 'C'], this is equivalent to calling
        // MP.api.funnel('A', 'B', 'C') ...
        MP.api.funnel.apply(MP.api, events).done(function(funnelData) {
          console.log('Results:');
          console.log(funnelData);
        });

This funnels query looks a little strange. Instead of just calling MP.api.funnel('eventA', 'eventB') like we normally would, we use MP.api.funnel.apply to call the endpoint with an array: MP.api.funnel.apply(MP.api, ['eventA', 'eventB']). These two styles of calling the api are equivalent. We use the second style so that we can pass the events in our eventsArray as arguments to the endpoint.

eventsArray is defined above, on line 16:

      var eventsArray = ['Signup'];

We add an event to this array every time the event select is changed:

      // When the event select changes, re-query and re-render
      eventSelect.on('change', function() {
        var event = eventSelect.MPEventSelect('value');

        // Only add events that we aren't already querying on
        if (!_.contains(eventsArray, event)) {
          eventsArray.push(event); // add the new event to our events array
          runQuery(eventsArray);
        }
      });

You may be wondering what the strange-looking _.contains function on line 38 is. At Mixpanel we use Underscore.js to improve our code readability and consistency. While it's not necessary to use Mixpanel Platform, knowing a few basic Underscore functions will help in understanding this tutorial (and in our opinion, make your JavaScript cleaner). Every time we introduce an Underscore function, we'll add a note explaining what it does.

New Underscore function: _.contains accepts an array and item as input; returns true if the array contains that item.

You'll want to initialize eventsArray with an event you track that you want to start your funnels with (a signup event is a good choice). If had an "Opened app" event, you could use:

      var eventsArray = ['Opened app'];

Try running your report to make sure everything is working. You should see output in the console every time you select an event:

Making a funnel query with events: ["Signup"]
Results:
[Object]
Making a funnel query with events: ["Signup", "Button clicked"]
Results:
[Object, Object]
Making a funnel query with events: ["Signup", "Button clicked", "Content searched"]
Results:
[Object, Object, Object]

Here is the current state of the code. Use this as a reference in case you get lost.




Setting up D3

The only setup required for D3 is adding its minified JavaScript to the head of your report. Add this <script> tag after the others in the <head> section:

  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" type="text/css" href="https://cdn.mxpnl.com/libs/mixpanel-platform/css/reset.css">
    <link rel="stylesheet" type="text/css" href="https://cdn.mxpnl.com/libs/mixpanel-platform/build/mixpanel-platform.v0.latest.min.css">
    <script src="https://cdn.mxpnl.com/libs/mixpanel-platform/build/mixpanel-platform.v0.latest.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script> <!-- Add this line -->
  </head>

D3 uses SVG to draw basic shapes on the page that represent your data. In order to render our visualization, we'll first need to set up the SVG element that will act as a canvas for D3 to draw on. Add this code at the bottom of your report, just before the closing </script> tag:

          ...
      }); // end of eventSelect.on handler - add code after this

      var width = 960;  // SVG element width
      var height = 640; // SVG element height

      var svg = d3.select("body").append("svg") // the SVG element
          .attr("width", width)
          .attr("height", height);

      var linkElements = svg.selectAll(".link"); // the link elements
      var nodeElements = svg.selectAll(".node"); // the node elements

      var colorScale = d3.scale.category20(); // used with colorIndex to select a different color for each node
      var countScale = d3.scale.linear().range([0, 40]);   // maps our link widths to values from 0px to 40px
      var strengthScale = d3.scale.linear().range([0, 1]); // maps our link strengths to values from 0 to 1

      var colorIndex = 0; // incremented to select a different color for each node
      var nodeIndex = 0;  // incremented to be display a funnel step number in node labels
    </script>

In addition to adding a DOM element for our SVG and preparing elements for our graph nodes and links, this piece of code also sets up scales that we will use to map our input data values to fixed ranges of color, node radius, and link width:

      var colorScale = d3.scale.category20(); // used in combination with colorIndex to select a different color for each node
      var countScale = d3.scale.linear().range([0, 40]);   // maps our link widths to values from 0px to 40px
      var strengthScale = d3.scale.linear().range([0, 1]); // maps our link strengths to values from 0 to 1

We'll talk more about the functionality of our scales when we actually use them later. For the impatient, look no further than the D3 documentation.

Here is the current state of the code. Use this as a reference in case you get lost.




Formatting funnel data

Our funnel query is retrieving data, but it's not in a form that D3 can interpret. We need to do some data manipulation - sum counts together, and turn our data into sets of nodes and links that can be rendered as a graph. Add these data structures underneath your eventsArray declaration (line 15):

      var eventsArray = ['Signup'];
      var nodesMap = {}, nodesArray = []; // stores our node data
      var linksMap = {}, linksArray = []; // stores our link data

Now we need to populate our nodesArray and linksArray data structures, by constructing a set of nodes and links from the results of our funnel query. Replace the code inside your MP.api.funnel .done callback with the following:

        // Call the funnel endpoint passing events
        // If events is ['A', 'B', 'C'], this is equivalent to calling
        // MP.api.funnel('A', 'B', 'C') ...
        MP.api.funnel.apply(MP.api, events).done(function(funnelData) {

          // Sum step counts
          var steps = _.map(funnelData, function(step) {
            return {
              event: _.first(_.values(step)).event,
              count: _.sum(_.pluck(step, 'count'))
            };
          });

          // Add nodes
          _.each(steps, function(step, i) {
            step = _.clone(step); // copy step so that it isn't modified
            var event = step.event;
            var count = step.count;

            if (_.has(nodesMap, event)) { // if the node already exists check its count
              // Each node's count should be the max value it has in any funnel
              nodesMap[event].count = _.max([nodesMap[event].count, count]);
            } else if (count) { // otherwise create node if it has a non-zero count
              // add the new node to our data structures
              nodesMap[event] = step;
              nodesArray.push(step);
            }
          });

          // Add links
          _.each(steps, function(step, i) {
            var nextStep = steps[i + 1];

            // Only create a link if nextStep exists and has a non-zero count
            if (nextStep && nextStep.count && _.has(nodesMap, nextStep.event)) {

              // Only create a link if one doesn't exist for these two events yet
              if (!_.has(linksMap, step.event + '-' + nextStep.event)) {
                var link = {
                  // link target and source values are set as corresponding nodes
                  source: nodesMap[step.event],
                  target: nodesMap[nextStep.event],
                  count: nextStep.count // link count is the target node count
                };
                // add the new link to our data structures
                linksMap[step.event + '-' + nextStep.event] = link;
                linksArray.push(link);
              }
            }
          });

          console.log('Nodes:');
          console.log(nodesArray);
          console.log('Links:');
          console.log(linksArray);
        });

This code formats our data in 3 steps. First, we sum together the range of dates we get from each step of the funnels query:

          // Sum step counts
          var steps = _.map(funnelData, function(step) {
            return {
              event: _.first(_.values(step)).event,
              count: _.sum(_.pluck(step, 'count'))
            };
          });

New Underscore functions:

This creates a data structure of the form:

steps = [{
    event: 'Signup',
    count: 4556
}, {
    event: 'Button clicked',
    count: 1231
}, ... ]

Next, we create a node for each step (skipping steps that already exist as nodes):

          // Add nodes
          _.each(steps, function(step, i) {
            step = _.clone(step); // copy step so that it isn't modified
            var event = step.event;
            var count = step.count;

            if (_.has(nodesMap, event)) { // if the node already exists check its count
              // Each node's count should be the max value it has in any funnel
              nodesMap[event].count = _.max([nodesMap[event].count, count]);
            } else if (count) { // otherwise create node if it has a non-zero count
              // add the new node to our data structures
              nodesMap[event] = step;
              nodesArray.push(step);
            }
          });

New Underscore functions:

This continually adds nodes onto our nodesArray. Nodes look like this:

nodesArray = [{
    event: 'Signup',
    count: 4556
}, {
    event: 'Button clicked',
    count: 1231
}, ... ]

Finally, we create links between our nodes, and add them to our linksArray. Links have source and target attributes that refer to nodes in our nodesArray. They also have a count attribute that will determine their width.

          // Add links
          _.each(steps, function(step, i) {
            var nextStep = steps[i + 1];

            // Only create a link if nextStep exists and has a non-zero count
            if (nextStep && nextStep.count && _.has(nodesMap, nextStep.event)) {

              // Only create a link if one doesn't exist for these two events yet
              if (!_.has(linksMap, step.event + '-' + nextStep.event)) {
                var link = {
                  // link target and source values are set as corresponding nodes
                  source: nodesMap[step.event],
                  target: nodesMap[nextStep.event],
                  count: nextStep.count // link count is the target node count
                };
                // add the new link to our data structures
                linksMap[step.event + '-' + nextStep.event] = link;
                linksArray.push(link);
              }
            }
          });

Links in the linksArray look like this:

linksArray = [{
    source: <a nodesArray node>,
    target: <a nodesArray node>,
    count: 4556
}, {
    source: <a nodesArray node>,
    target: <a nodesArray node>,
    count: 1231
}, ... ]

Commit your report and try running it to make sure everything works. If you open the JavaScript console you should see your formatted data every time you select an event:

Making a funnel query with events: ["Signup"]
Nodes:
[Object]
Links:
[]
Making a funnel query with events: ["Signup", "Button clicked"]
Nodes:
[Object, Object]
Links:
[Object]
Making a funnel query with events: ["Signup", "Button clicked", "Content searched"]
Nodes:
[Object, Object, Object]
Links:
[Object, Object]

We're almost there. Now we need D3 to step in and prepare our data for a visual representation. For that, we'll use D3's force layout. A layout in D3 is a formatting tool that turns input data into something that can be rendered to SVG. There are many different layouts for different data visualizations; in our case, the force layout will simply add x/y positions to our nodes. Add this code after your other D3 code, just before the closing </script> tag:

      ...

      // The D3 Force Layout that calculates our node and link positions
      // See https://github.com/mbostock/d3/wiki/Force-Layout for detailed documentation
      var forceLayout = d3.layout.force()
        .nodes(nodesArray) // link our node data with the layout
        .links(linksArray) // link our link data with the layout
        .size([width, height])
        .charge(-120) // node repulsion/attraction - should always be negative
        .linkDistance(300) // the distance nodes are placed from each other
        .linkStrength(function(d) { return strengthScale(d.count); }) // set link strength based on count
        .on('tick', function() { // a function that runs continuously to animate node and link positions
          linkElements // update link positions
            .attr('x1', function(d) { return d.source.x; })
            .attr('y1', function(d) { return d.source.y; })
            .attr('x2', function(d) { return d.target.x; })
            .attr('y2', function(d) { return d.target.y; });

          nodeElements // update node positions
            .attr('cx', function(d) { return d.x; })
            .attr('cy', function(d) { return d.y; });
        });
    </script>

When we run the layout on our nodesArray and linksArray, x and y attributes will be added to the nodes that will be used by the .on('tick', function() { ... above to position our nodeElements and linkElements. Now we just need to add these elements to the SVG container.

Here is the current state of the code. Use this as a reference in case you get lost.




Rendering the graph

What we need now is a function that takes our data and renders an SVG element for each piece of it. The attributes of the SVG element - size, position, color, text - should be set according to the data. This task is exactly what D3 is for. Add this function at bottom of your report, before the closing </script> tag:

      function renderGraph() {
        var maxCount = _.max(_.pluck(nodesArray, 'count'));

        // update scale domains for the new data
        countScale.domain([0, maxCount]);
        strengthScale.domain([0, maxCount]);

        // our data has changed - rebind layout data to SVG elements
        nodeElements = nodeElements.data(forceLayout.nodes());
        linkElements = linkElements.data(forceLayout.links());

        nodeElements.enter() // for any new node data, add corresponding node elements
          .append('circle')
            .attr('class', 'node')
            .attr('r', function(d) { return countScale(d.count); }) // set node circle radius
            .style('fill', function(d) { return colorScale(++colorIndex); }) // set node color
            .call(forceLayout.drag);

        linkElements.enter() // for any new link data, add corresponding link elements
          .insert('line', ':first-child') // prepend links so that they are always behind nodes
            .attr('class', 'link')
            .style('stroke-width', function(d) { return countScale(d.count); }); // set link width based on count

        // (Re)start the layout - must happen any time node or link data changes
        // https://github.com/mbostock/d3/wiki/Force-Layout#start
        forceLayout.start();
      }
    </script>

This is a complex looking function, but each piece serves a simple purpose. First, we update the domains of our scales with the max count from our nodesArray:

        // update scale domains for the new data
        countScale.domain([0, maxCount]);
        strengthScale.domain([0, maxCount]);

A scale in D3 is just a mapping of an input domain to an output range. So if our max count is 4000, and our scale has a range of 0 to 100, 4000 would be mapped to 100, 2000 would be mapped to 50, and 0 would be mapped to 0. This is useful because we usually don't want our data values to directly determine pixel values; we don't want a node circle with a radius of 4000px just because the node count is 4000. In our case, we have a countScale that maps counts to the range 0 to 40 pixels and will be used for node radius and link width. The strength scale maps to the range 0 to 1 and controls the "strength" of links in our layout - how easily they stretch or contract.

The next section is the core of our function. We take the link and node data from the force layout, and bind it to the linkElements and nodeElements. These are called selections, and are basically D3-wrapped sets of SVG elements:

        // our data has changed - rebind layout data to SVG elements
        nodeElements = nodeElements.data(forceLayout.nodes());
        linkElements = linkElements.data(forceLayout.links());

Next, we have code that actually renders SVG elements for our nodes. The enter call on the selection is the key to D3 - it represents each new piece of data, that doesn't yet have an SVG element associated with it. By calling nodeElements.enter().append(), we are creating a new circle SVG element for each node in our data.

        nodeElements.enter() // for any new node data, add corresponding node elements
          .append('circle')
            .attr('class', 'node')
            .attr('r', function(d) { return countScale(d.count); })
            .style('fill', function(d) { return colorScale(++colorIndex); })
            .call(forceLayout.drag);

The rest of the code here is setting attributes and styles on the node elements, just like jQuery. We set the class as 'node', the cirle radius (the 'r' SVG attribute) as scaled relative to our node's count, and we pick a new color for each SVG 'fill' attribute using our colorScale. The last function call is important:

            .call(forceLayout.drag);

This sets up draggable functionality on our nodes, something D3's force layout gives us for free. forceLayout.drag is a function that sets up the draggable behavior on an element, and .call executes that function on each element. With this simple addition, are nodes are draggable with the mouse.

Here's the same type of D3 invocation for adding SVG elements for our links:

        linkElements.enter() // for any new link data, add corresponding link elements
          .insert('line', ':first-child') // prepend links so that they are always behind nodes
            .attr('class', 'link')
            .style('stroke-width', function(d) { return countScale(d.count); }); // set link width based on count

The only strange thing here is that we use .insert('line', ':first-child') instead of .append('line') - this prepends the link elements instead of appending them to the SVG container. We do this so that our links are always drawn underneath our nodes. SVG is simple that way; whatever element is placed last in the SVG document is put on top.

Finally, let's render our graph! In the MP.api.funnel .done callback, replace the logging code at the bottom:

            console.log('Nodes:');
            console.log(nodesArray);
            console.log('Links:');
            console.log(linksArray);
        });

with a call to renderGraph:

          renderGraph();
        });

Commit your code, run it, and make sure there aren't any errors. If you select a few events, you should see some orbs floating around, but it still isn't a graph. We need to add a little styling before we can see our links.

Add this <style> block at the end of the head tag at the top of your report:

    ...
    <style>
      svg {
        background: #fff;
      }

      .node {
        stroke-width: 0;
        fill-opacity: 0.4;
      }

      .node text {
        text-anchor: middle;
        alignment-baseline: middle;
        fill-opacity: 1;
      }

      .link {
        stroke: #999;
        stroke-opacity: .6;
        stroke-linecap: round;
      }
    </style>
  </head>

Commit, run, and add some events. Now this is starting to look like a graph! Try dragging nodes around and see how the force layout causes them to behave. However, we're not quite there yet. Our "graph" right now only represents one funnel, since we're only making one funnel query. We want to show multiple funnels as a network of paths between events.

Here is the current state of the code. Use this as a reference in case you get lost.




Querying multiple funnels

What we want to do for a given list of events - A, B, C, D - is query every funnel between A and D: [A, D], [A, B, D], [A, C, D], and [A, B, C, D]. This will create a graph of the various routes users take between events A and D.

In other words, for a given list of events, we want every sublist of those events. We can do this with a recursive function, which you should add at the bottom of your report, before the closing </script> tag:

      // Takes a list, returns all subsets of that list (excluding the empty set - [])
      // See http://stackoverflow.com/questions/5752002/find-all-possible-subset-combos-in-an-array
      function getSubsets(input){
        var allSubsets = [];
        var addSubsets = function(n, input, subset) {
          if (n) {
            _.each(input, function(item, i) {
              addSubsets(n - 1, input.slice(i + 1), subset.concat([item]));
            });
          } else if (subset.length) {
            allSubsets.push(subset);
          }
        }
        _.each(input, function(item, i) {
          addSubsets(i, input, []);
        });
        allSubsets.push(input);
        return allSubsets;
      }
    </script>

We also want to wrap each sublist with the first and last event in the funnel, since we're looking at paths between those two events. We'll use the function below to call getSubsets and wrap the output lists (add it below getSubsets, before the closing </script> tag):

      // Gets all sub-funnels from a list of events, always including the first and last event
      // [A, B, C, D] =>
      //   [ [A, D],
      //   [A, B, D],
      //   [A, C, D],
      //   [A, B, C, D] ]
      function getFunnels(events) {
        if (events.length <= 2) { // No sub-funnels if there are 2 or less events
          return [events];
        }

        var first = events[0];
        var last = events.slice(-1)[0];
        var rest = events.slice(1, -1);

        var funnels = [[first, last]];
        var subFunnels = getSubsets(rest)

        _.each(subFunnels, function(funnel) {
          funnel = [first].concat(funnel).concat([last]);
          funnels.push(funnel);
        });

        return funnels;
      }
    </script>

Now we want to query with all of our sub-funnels instead of just the events in eventsArray. Find the eventSelect.on('change', function() { ... handler (line 109) and replace the runQuery call:

          runQuery(eventsArray);

with the looping code below:

      // When the event select changes, re-query
      eventSelect.on('change', function() {
        var event = eventSelect.MPEventSelect('value');

        // Only add events that we aren't already querying on
        if (!_.contains(eventsArray, event)) {
          eventsArray.push(event); // add the new event to our events array

          var funnels = getFunnels(eventsArray);
          _.each(funnels, function(funnel) { // loop over funnels
            runQuery(funnel);
          });
        }
      });

Commit and re-run your report. When you add events, you should see their nodes fully connected by links:

Congrats, you've now created a full multi-funnel graph!

Here is the current state of the code. Use this as a reference in case you get lost.




Finishing touches

Our graph looks cool, but it needs one more element before it'll be useful: labels. We have no way of telling which event each node corresponds with, or what number of events each link width represents. Lucky for us, D3 makes the process of adding dynamic labels straightforward. We'll be modifying the renderGraph function:

      function renderGraph() {
        var maxCount = _.max(_.pluck(nodesArray, 'count'));

        // update scale domains for the new data
        countScale.domain([0, maxCount]);
        strengthScale.domain([0, maxCount]);

        // our data has changed - rebind layout data to SVG elements
        nodeElements = nodeElements.data(forceLayout.nodes());
        linkElements = linkElements.data(forceLayout.links());

        nodeElements.enter() // for any new node data, add corresponding node elements
          .append('circle')
            .attr('class', 'node')
            .attr('r', function(d) { return countScale(d.count); }) // set node circle radius
            .style('fill', function(d) { return colorScale(++colorIndex); }) // set node color
            .call(forceLayout.drag);

        linkElements.enter() // for any new link data, add corresponding link elements
          .insert('line', ':first-child') // prepend links so that they are always behind nodes
            .attr('class', 'link')
            .style('stroke-width', function(d) { return countScale(d.count); }); // set link width based on count

        // (Re)start the layout - must happen any time node or link data changes
        // https://github.com/mbostock/d3/wiki/Force-Layout#start
        forceLayout.start();
      }

To add node labels, we'll need to add an extra element to our nodes - an SVG text element. Because nodes will now consist of a <circle> and a <text> element, they'll need to be wrapped in a g, which is an SVG tag for containing a "group" of elements. With this wrapper we'll be able to position our circle and text together.

Replace our node rendering code:

        nodeElements.enter() // for any new node data, add corresponding node elements
          .append('circle')
            .attr('class', 'node')
            .attr('r', function(d) { return countScale(d.count); }) // set node circle radius
            .style('fill', function(d) { return colorScale(++colorIndex); }) // set node color
            .call(forceLayout.drag);

with the new <text> and <circle> rendering code shown here:

        var nodeContainers = nodeElements.enter() // for any new node data, add corresponding node elements
          .append('g')
            .attr('class', 'node')
            .call(forceLayout.drag);

        nodeContainers
          .append('circle') // for each new node, add a circle element
            .attr('r', function(d) { return countScale(d.count); }) // set node circle radius
            .style('fill', function(d) { return colorScale(++colorIndex); }) // set node color
            .attr('cx', 0)
            .attr('cy', 0);

        nodeContainers
          .append('text') // for each new node, add a label
            .text(function(d) { return ++nodeIndex + ' ' + d.event; });

For each node, this creates a <g> instead of a <circle>, and appends <circle> and <text> elements to it. The only other change we'll need to make is within the .on('tick', function() { ... handler in our forceLayout declaration (line 145):

        .on('tick', function() { // a function that runs continuously to animate node and link positions
          linkElements // update link positions
            .attr('x1', function(d) { return d.source.x; })
            .attr('y1', function(d) { return d.source.y; })
            .attr('x2', function(d) { return d.target.x; })
            .attr('y2', function(d) { return d.target.y; });

          nodeElements // update node positions
            .attr('cx', function(d) { return d.x; })
            .attr('cy', function(d) { return d.y; });
        });

Replace the nodeElements positioning code:

          nodeElements // update node positions
            .attr('cx', function(d) { return d.x; })
            .attr('cy', function(d) { return d.y; });

with the new code shown here:

          nodeElements // update node positions
            .attr("transform", function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });

Since we're now positioning a <g> element instead of a <circle>, we need to set the transform attribute instead of cx and cy, which are <circle>-specific in SVG.

Finally, let's add some <title> elements to our links that will display a tooltip when you mouse over each link. In renderGraph, replace the linkElements.enter() code below:

        linkElements.enter() // for any new link data, add corresponding link elements
          .insert('line', ':first-child') // prepend links so that they are always behind nodes
            .attr('class', 'link')
            .style('stroke-width', function(d) { return countScale(d.count); }); // set link width based on count

with this new version that appends a <title> element:

        linkElements.enter() // for any new link data, add corresponding link elements
          .insert('line', ':first-child') // prepend links so that they are always behind nodes
            .attr('class', 'link')
            .style('stroke-width', function(d) { return countScale(d.count); }) // set link width based on count
            .append('title')
              .text(function(d) { return d.count; });

That's all you need! Commit your work, and take a look at what you've accomplished. You should see something like this:

You've now fully integrated D3 with Mixpanel Platform and have built a fairly complex visualization in the process. D3 has a high learning curve but is a powerful tool especially when used alongside Mixpanel Platform, since any data visualization is only as good as the data that fuels it. For more information on D3, as well as dozens of examples, explore the D3 homepage.

Here is the completed report. Use this as a reference in case you get lost.