Part 2: SPFx and D3 Trees

In part one of this series I managed to get D3 up and running inside an SPFx client web part. If you don’t know what D3 is, or SPFx for that matter, then I suggest you start the journey at the 3 links provided in the first sentence.

TBH, I am not entirely sure where this journey is going to take us but then SharePoint is journey and not a destination – am sure that someone more spiritually profound than I should have said that. However, I have had this idea in the back of my head for some time now. I want to provide a new way in which users can access documents and list items in a more contemporary and engaging fashion without all the navigation clicks and post-backs traditionally required to navigate a folder structure or view that is grouped by different metadata attributes. My idea is to use D3 to provide a navigation tree where users can glide their way to target documents and list items with ease.

Conceptually, it should be quite easy. We just have to:

  • Get D3 to work within a SPFx client web part (that’s what I did in part one).
  • Get D3 to be able to present data (any old data for now) in a tree that provides the slinky UI I am after (that’s what we’ll look at in this post).
  • Make it look it a little less like a D3 tree demo and more like a SharePoint data navigation tool (that’s the next post)
  • Find a way to feed the navigation tree with real data from SharePoint.
  • Provide something useful for the users to do with the UI like use it to access list items and documents.

In the inimitable words Miranda Hart – such fun!

D3 Trees

If you start researching D3 then it soon becomes clear that most people see it as a chart generating API for Business Intelligence (BI) data visualisations. This is indeed a key purpose of D3 but it has much more to offer than that. If you search for D3 Trees then you will find enough working examples out there to whet your appetite as to what might be possible, such as this one:

When constructing our tree, there are however a few challenges ahead of us however:

Lack of Documentation

First up, despite there being several demo trees out there, complete with source code to peer at, most of the literature is focused on the more traditional charting capabilities of D3. Indeed, I shelled out $50 for a highly rated text from Amazon and it barely mentioned trees, let alone providing details about how to construct them. Of course, it could be that I just haven’t found the right sources yet, so if you do find something useful please do share.

D3 Versions

It seems that D3 went through a complete rebuild between version 3 and 4 and, at the time of writing, we are on version 5.7! The concepts are essentially the same but the API is substantially different to the extent that most of the demos you come across are written for v3 and simple won’t work in v4 or above. There is ongoing work to provide more examples in v4 but to those of us on a steep learning curve it is rather confusing. The differences between v4 and v5 don’t seem to be nearly so marked and it looks like most of demos and samples build for v4 will also work in v5.

TypeScript

Copy/pasting some else’s D3 working JavaScript example into a Content Editor Web Part (CEWP) is simple as! The problem is that in SharePoint Modern, we don’t have a CEWP. And even if we did find a way to run naked JavaScript on a Modern page, we’d be missing out on the opportunity to provide configuration settings that cane be tweaked by users. That’s why I want to be able to encase D3 in a web part.

However, developing client web parts with the SPFx will require us to get to grips with TypeScript and that means we have to somehow write code in TypeScript and not bear arse JavaScript. That means we can’t just past in someone else’s code and start tinkering. Instead we’ve got to write code ourselves and if I thought that documentation of D3 trees (that work with v4 and above) was thin on the ground, code examples written in TypeScript that interact with D3 are virtually non-existent – how scary is that?

Ironically, I think this is quite a good thing, for me at least, as it means there are no shortcuts. If I’m going to realise my vision for a beautiful D3 tree in an SPFx web part then it means I’ve got to fundamentally understand what every line of code is for and what it does. This is something that is naturally expected of me as a C# developer of course but in the wild west that is JavaScript I am convinced that many developers simply have no clue as to how their solutions actually work at the nuts and bolts level, as the paradigm seems to be just rip a function from here, there and everywhere, glue together a fragile tapestry of 3rd party libraries and don’t worry how it all works – just so long as it does work!

Enough of the sarcasm and jibber jabber, let’s go on with it. Be aware that this is not going to be a 5-minute exercise but the pain is definitely worth the gain IMHO, so stick with me as we take the plunge.

Our First Tree

I’m assuming that you are comfortable with how I created a new SPFx project (without the need for React or Knockout support) and how I imported D3 as explained in my previous article.

Grab some data

First thing we need is to grab some data. In due course we will need to suck data up from SharePoint but for the moment we’ll make do by using some statically defined data in a web part property as shown below:

  private treeDataSet =
  {

    "name": "Documents",  
    "children": [
      {
        "name": "Folder A",       
        "children": [
          {"name": "Doc 1"},
          {"name": "Doc 2"}]
      },
      {
        "name": "Folder B",       
        "children": [
          {"name": "Doc 3"},
          {"name": "Doc 4"},
          {"name": "Doc 5"},
          {"name": "Doc 6"},
          {"name": "Sub Folder B-1",          
           "children": [
              {"name": "Doc 7"},
              {"name": "Doc 8"},
              {"name": "Doc 9"},
              {"name": "Doc 10"}
            ]
          }
        ]
      }
    ]
  };

This data is just defined as a simple JSON object with each element (node) having a “name” property that contains the text we want to display next to the tree node and optionally an array of “children” objects of the same simple structure. All nodes, with the exception of the root node, will therefore have a parent node and any nodes without children will end as leaf nodes in the tree. Sounds like a tree to me!

D3 trees expect the data in this format and we are using the default property names here i.e. “name” and “children”, as this is what D3 natively understands and so will make life simpler. Of course, we can embellish and enhance the data associated with each node in any way we want but this is the minimum and simplest data structure we need in order to construct a tree.

Load the data into a hierarchy

A D3 tree is actually a type of data visualisation known as a hierarchy layout. There are 5 main types of hierarchy layout as follows:

  • Cluster
  • Pack
  • Partition
  • Tree
  • Treemap

For the purpose of today’s exercise, I am going to be using the Tree layout because that’s the layout I want to showcase.

We can’t just send the raw data to a tree directly. First, we have to process it in an abstract construct in D3, known as a Hierarchy.

I have set up a rootNode property in the web part class which returns us a d3 hierarchy object and fed it with our raw data set, as shown below:

  private _rootNode: any = null;
  public get rootNode(): any {
    if (this._rootNode == null){
      this._rootNode = d3.hierarchy(this.treeDataSet);    
    }
    return this._rootNode;
  }

Actually, the d3.hierarchy method return a tree node (strictly speaking a HierarchyNode) which is an entry point to the hierarchically structured data.

I think this is a rather elegant way of doing things because it means that we can potentially use the same hierarchy data construct in any one of the 5 non-abstract layouts. We’ll come to creating the tree layout a bit later but first I want to step through how to render the tree starting with the render method of the web part.

The web part render method

As I am sure you are aware, the render method of the web part is where all the action takes place. It’s where we define the business logic and data that generates the output to be displayed in the web part and so it makes sense to continue on the rest of our journey from there.

rendermethod

The render method shown above is deceptively simple.

First, I create a simple div element and add it as the inner HTML of the web part DOM.

Notice how I set the style of the div to be the named class that was set up for us by Yeoman when the web part was provisioned. This will ensure that any child CSS classes we may wish to define and use with child elements to be injected into this container div later on, will be picked up. If you don’t set the parent CSS class here, any attempt to use CSS classes defined in the webpart.module.scss file, as class attributes of child elements, simply won’t be picked up.

I then make just 2 calls to methods that I have defined in a support class called D3Utilities. This support class has been declared in a separate TypeScript file and imported into file in which the web part class is defined. This is just to make my web part class lean and as clean as possible. I find that if you don’t organise things in this way, your web part class very scruffy rather quickly.

I’ll come back to the D3Utilities.initialiseNodes method later but for now I want to look at the renderChart method but before we dig into that I need to explain about the parameters that are passed into this method.

The first is the tree node that is the source node from which the tree should be rendered. Initially this will be the root node of the tree and so that it why the rootNode property is passed in when we call the render method, but subsequent calls are made to this method when a user clicks on a node with children, to expand or collapse it and when that happens the node that was clicked is what is passed in and that is not necessarily the root node.

The second parameter is an instance of a ChartConfig class, which is also defined as a property of the web par. Let’s take a look at the ChartConfig class.

The ChartConfig Class

This class is a simple enough construct which I defined in a separate TypeScript file in the project. This class means we can define a single configuration object to get passed around between method calls rather than send round a messy set of individual parameters.

The ChartConfig class definition is shown below:

export class ChartConfig {

  public svg: any;
  public treeLayout: any;
  public layerSpace: number;
  public root: any;
  public link: LinkType;
  public layout: LayoutType;

}

The ChartConfig class defines a couple of properties that are enumeration types defined in yet another file called, enums.ts, as shown below:

export enum LayoutType {
  LeftToRight,
  RightToLeft,
  TopDown,
  BottomUp
}

export enum LinkType{
  Arc,
  Line,
  Elbow
}

My plan is for the web part to support different orientations of the tree and different types of line connectors that will join tree nodes. In due course, we can externalise these properties so that they can be configured via the web part’s property panel. In fact, we’ll be looking at just that in the next post in this series. So, these properties really represent a bit of forward planning!

D3 was designed primarily to draw visualisation on a Scalable Vector Graphic (SVG) element. A detailed explanation of the SVG element is way beyond what I intend for this article but what’s important is that I am using the svg property of the ChartConfig class to hold a reference to the SVG element onto which we shall render the tree. The plan is:

  • Tree node data gets added to a D3 tree,
  • The tree gets added to the svg,
  • And the svg to get injected into the containing div defined in the web part render method.

Still with me? Good!

The treeLayout property will hold reference to the D3 tree object that we will use to generate the tree. In due course, we will pump in the data from the hierarchy object presented earlier. Remember that a D3 hierarchy is an abstract construct and therefore we cannot visualise it directly – it can only be used indirectly via one of the derived concrete classes i.e. a D3 tree in our case.

The layerSpace property will be used to specify the number of pixels between the layers in the tree view. In a left-to-right tree, this will be the separation between each column of nodes in the x axis.

And finally, the root property will provide us with a reference to the rootNode hierarchy data.

It may seem odd that we pass the rootNode hierarchy as a both a separate parameter to the renderChart method and as a separate element of the config, ChartConfig object but as explained above, this is because the renderChart method will be called whenever a node, that has children, is clicked and so the rootNode being passed in here is really a special case when we first render the tree. So, the first parameter in the renderChart method will provide a handle to a specific source node whereas the root property of the ChartConfig will provide use access to the root node of the tree data. We need both of these elements to process updates to the tree data and render the tree.

The chartConfig property

Back in the web part class, we can declare an instance of the ChartConfig class that can be passed to the renderChart method. The definition of the chartConfig property is shown below:

  private _chartConfig: ChartConfig = null;

  public get chartConfig(): ChartConfig {

    if (this._chartConfig == null){

      this._chartConfig = new ChartConfig();
      this._chartConfig.svg = this.svg;
      this._chartConfig.treeLayout = d3.tree().size([this.svgHeight, 0]);         
      this._chartConfig.layerSpace = Constants.LAYER_SPACE;
      this._chartConfig.root = this.rootNode;
      this._chartConfig.link = this.NodeConnectionType;
      this._chartConfig.layout = this.Layout;

    }

    return this._chartConfig;

  }

The svg property

The svg object used to instantiate the chartConfig property is defined as another property of the web part as shown below:

  private _svg: any = null;

  public get svg(): any {

    if (this._svg == null){

      var __svg = d3.select("#d3Test")
                      .append("svg")
                      .attr("width", "100%")                                     
                      .attr("height", this.svgHeight)                  
                      .call(d3.zoom().on("zoom", function () {                     
                        __svg.attr("transform", d3.event.transform);
                    }))
                      .append("g")
                      .attr("transform", `translate(${Constants.MARGIN_LEFT}, {Constants.MARGIN_TOP})`);                     

      this._svg = __svg;
                
    }

    return this._svg;   

  }

What we are doing here is appending an SVG element inside of the web part’s containing div.

First, the containing div is selected by its ID and we use D3’s chaining syntax to append an SVG element and set its width to be 100% and its height to be a value set in the svgHeight property of the web part as shown below:

  private _svgHeight = -1;

  public get svgHeight() {

    if (this._svgHeight == -1){

      this._svgHeight = Constants.SVG_DEFAULT_HEIGHT - Constants.MARGIN_TOP - Constants.MARGIN_TOP;

    }

    return this._svgHeight;

  }

As can be seen, the dimensions of the svg are defined by some constant values, specified in a Constants class defined in the separate constants.ts file. I do love separation.

The Constants class (in its entirety) is shown below:

export abstract class Constants
{

    public static readonly SVG_DEFAULT_HEIGHT : number = 500;  

    public static readonly MARGIN_TOP : number = 0;

    public static readonly MARGIN_BOTTOM : number = 0;

    public static readonly MARGIN_LEFT : number = 100;

    public static readonly MARGIN_RIGHT : number = 100;

    public static readonly DURATION_NODE_TRANSITIONS : number = 750;

    public static readonly LAYER_SPACE: number = 150;   

}

An explanation of D3 chaining syntax is beyond the intended scope of this article but it is similar to that used in jQuery where the result of one expression or function is passed to the next in the chain, where chained elements are separated using dot notation.

When defining the svg element, I set up default zooming and panning, because why wouldn’t we want to be able to zoom and pan?

However, to get pan and zoom to work we actually need to append a containing group (“g”) node to the svg and then transform our view so that we have the everything nicely in focus. As we are going to place the text for the root node out the left, you need to set a transformation to shift things to the left a bit or else you’ll end up cutting off this text when the tree is first rendered. Setting a comfortable margin around the containers is considered good practice.

The keen eyed amongst you will have noted that the returned object is actually the group element and not the svg but as group elements are simply containers and have no physical presence and we are only adding the group element to get zoom and pan to work then we can get away with referencing this as the svg element as I think it make things clearer.

The treeLayout property

As mentioned above, when instantiating the chartConfig we also create a new d3.tree object and assign it to the treeLayout property of the configuration. The height of the tree is set to the same height of the containing svg i.e. the value returned by the svgHeight property of the web part.

It seems that you can set the width property of the tree to be any numeric value you like (so I set it to zero) as it appears to have no influence on the horizontal space consumed by the tree. I’m not entirely sure why this is. It could be something that magically happens when zooming is enabled or when we assign the width of the svg to be 100% of the parent container. Anyway, this is how we want things to be, so that the tree will expand to consume the available width of the web part.

When you add web parts to a page the width will be dynamically set depending on the number of columns specified in the layout of the section of the page where the web part resides. The width of the svg will dynamically resize itself according to whatever SharePoint makes available through the Responsive Design of the Modern UI controlled by the device and size of viewport used to access the page. This is how we will want things to be. Setting a fixed width will likely screw up our Responsive Design layouts in SharePoint, so we need the width of the svg be dynamic.

Height is a different matter though. We can set a standard web part height, as I have done here at 500px, but that is unlikely to serve all scenarios and so we might want to think about making the web part height a property of the web part itself. Just a thought, we’ll leave that for another day.

The renderChart method

It’s taken a while to get here but we are now ready to delve into the inner workings of the renderChart method. Remember, this method is defined in the D3Utilities class that I create for this project. The method is shown in its entirety below:

public static renderChart(sourceNode: any, config: ChartConfig){

    // Assigns the x and y position for the nodes
    let treeData = config.treeLayout(config.root);       

    //Get all the nodes generated by the call to treeLayout
    let nodes = treeData.descendants();

    //Set the space between layers of nodes in the horizontal
    D3Utilities.setLayerSpacing(nodes, config.layerSpace);
                                         
    const node = config.svg.selectAll("g").data(nodes, d => d.id);                           
                                           
    let nodeEnter = D3Utilities.enterNode(node, sourceNode, config)
                          .on('click', function(d){
                                          D3Utilities.nodeClick(d, config);
                                       });

    let nodeUpdate = nodeEnter.merge(node);

    D3Utilities.updateNode(nodeUpdate, config);
       
    D3Utilities.exitNode(node, sourceNode, config);           

    //Get a handle on the links between nodes
    let links = treeData.descendants().slice(1);       

    let link = config.svg.selectAll('path').data(links, d => d.id);     

    let linkEnter = D3Utilities.enterLink(link, sourceNode, config);                    

    let linkUpdate = linkEnter.merge(link);

    D3Utilities.updateLink(linkUpdate, config);

    D3Utilities.exitLink(link, sourceNode, config);    

    D3Utilities.savePreviousLocation(nodes);

}

First up, we load the tree (passed in as the config.treeLayout parameter) with the data from the root node and all nodes in the hierarchical data set. This calculates the x/y positions of all nodes in the area assigned to the tree. Note that the number of nodes to be rendered will likely vary between method calls as we expect users to expand and collapse tree nodes at their discretion. That’s why we need to load the tree date every time this method is called as it recalculates the coordinates for each node depending on data.

Next, we get the reference to all the nodes that will exist in this rendering of the tree and call the setLayerSpacing method. This method then make sure that there is a specified width between the different layers in the tree on the horizontal (x) axis.

public static setLayerSpacing(nodes: any, layerSpace: number){      

    nodes.forEach(function(d) {      
                    d.y = d.depth * layerSpace;
                  });

}

The magic d parameter gives us a handle on the depth of the node (which layer it sits on) and this allows us to set the y co-ordinate accordingly.

Now, I don’t quite get this bit. I would have thought that setting d.x would control the horizontal spacing but if you set d.x instead of d.y then everything ends up in a single vertical line. I’m sure there is a logical reason for this but just not sure what it is?

Then we get a reference to all group elements defined within the svg. Nodes in the tree will actually be defined as groups elements so what we are really getting is the set of tree nodes that are currently rendered.

Then what we do is add (known in D3 as Enter) any new nodes, update any existing nodes and remove (known as Exit in D3) any nodes that no longer need to be rendered. Then we do the same for the links that get drawn to connect the nodes.

That’s all very easy to say but let’s take a look at the methods where all the magic happens.

Enter Stage Left, Exit Stage Right

Based on the data that’s in the tree data set and the nodes that are already rendered (or not rendered) in the svg, D3 magically figures out which nodes are missing and need to be added to the visualisation, those nodes which already exist and just need updating and those nodes which are no longer needed as so can be disposed of. So, to process the nodes to be rendered we need to do things in 3 stages:

  • Enter: Add any new nodes not already present
  • Update: Update existing nodes
  • Exit: Remove any nodes no longer needed

You can see the 3 main methods in the renderChart method that achieves this, namely:

  • enterNode
  • updateNode
  • exitNode

All 3 are defined as static methods in the D3Utilities class I created for this project.

Incidentally, TypeScript doesn’t allow you to define a class as static, like you can in C# for example. However, you can create the class as abstract and then declare static methods and that amounts to essentially the same thing and so that’s what I have done here.

export abstract class D3Utilities{

    //Returns true if the node has no child nodes

    public static someMethod(parameter: {

        //Do something
        return  ;       

    }

}

The enterNode method

Here’s the code:

public static enterNode(node: any, sourceNode: any, config: ChartConfig): any{

    //Append a group node
    let nodeEnter = node.enter().append('g')
                    .attr("class", `${styles.node}`)
                    .attr("transform", function(d){
                                            return `translate(${sourceNode.y0}, ${sourceNode.x0})`;                                             
                                        });

    //Append the text
    nodeEnter.append('text')           
        .attr("dy", ".35em")              
        .attr("x", function(d) {
                       return D3Utilities.hasChildren(d) ? -12: 12;
                   })
        .attr("text-anchor", function(d) {
                                return D3Utilities.hasChildren(d) ? "end" : "start";                                                                       
                             })                                 
        .text(function(d) {
            return d.data.name;
        });

    //Append a circle
    nodeEnter.append('circle')
        .attr("class", `${styles.node}`)                 
        .attr('r', 0)
        .style("fill", function(d: any) {
                          return d._children ? "lightsteelblue" : "white";
                       });
           
    return nodeEnter;

}

The method is passed in a reference to all the nodes that currently exist in the svg. That’s what the following line of code in the renderChart method does for us:

const node = config.svg.selectAll("g").data(nodes, d => d.id);

Remember that nodes are actually going to be defined as group (“g”) elements. So, the call to enterNode will return the set of nodes that are required according to the tree data.

The second parameter, the sourceNode, provides a reference to the node that is in focus. Initially that will be the root node in the tree but in subsequent calls this will be the node that users click in the UI to either expand or collapse its child elements.

The process of creating a tree node goes like this:

  • For each element in the tree data set, append a group node and move it into position.
  • Then append a text node to the group and position the text in the x and y axis as an offset from the text-anchor point according to whether the node has any children or not. Nodes with children will have their text positioned on the left and those without will have their text on the right. This just makes for a less cluttered layout. Note that the magic d parameter allows us to access the node and its data via the data property. Although the source tree data does not define a data property, when we created the tree layout, which transformed the raw data by adding x and y coordinates for each node, at the same time it created a data property and shifted all the properties defined in the raw source data as attributes of this new data property. So that’s why we need to access the node’s text as d.data.name rather than simple d.name.
  • Then append a circle to physically present a node-like object that can be seen in the UI. Based on whether node has children and whether it is expanded or collapsed, we fill the circle with either a light steel blue or a white colour.

That last bit looks a bit odd because I’m not setting the colour of the node circle on the children property but on the _children property – WTF?

Let me explain how the concept of expandable trees work. In the source data set we have seen that elements can be defined with child elements in the children property. D3 looks for this children property and any data elements it may contain in order to determine the number of nodes that should be rendered and how they should be laid out in the svg. But we want an expandable/collapsible tree and so we need to squirrel away this child data somewhere when the node is collapsed and then restore it when the node is expanded.

Probably the easiest way to do this is by setting up a _children property on our node object. The logic goes like this. When a node is to be collapsed, remove all the data in the children property and assign it to the _children property. When the node is expanded do the reverse; copy at the data from the _children property to the children property.

Knowing that D3 is only going to pick up and render nodes that are defined in the children property we can effectively maintain the expanded/collapsed state of the node.

But where do we make this magical data switch, I hear you ask (go on ask). Well that happens in the click event for the node. Look back at the following line of code in the chartRender method and you will see that each node is assigned a function method that is called whenever the node is clicked.

let nodeEnter = D3Utilities.enterNode(node, sourceNode, config)
                               .on('click', function(d){
                                           D3Utilities.nodeClick(d, config);
                                             });

This is the code that is in the nodeClick method:

public static nodeClick(d: any, config: ChartConfig){

    if (!D3Utilities.isLeafNode(d)){         

        if (d.children) {         

            d._children = d.children;

            d.children = null;           

            }

        else {

            d.children = d._children;

            d._children = null;           

        }           

        D3Utilities.renderChart(d, config);

        }

}

You can see what’s going on here. First the code checks to see if the node is a leaf node by calling the isLeafNode method:

//Returns true if the node has no child nodes

public static isLeafNode(node: any): boolean{

    return (node.children == null) && (node._children == null);       

}

This method returns true if the node has no children at all i.e. both the children and the _children properties are empty or don’t exist.

We check whether the node is leaf node or not because, at the moment, we are only interested in expanding and collapsing nodes with children. In due course we will want to do something different when the user clicks on a leaf node but that for us to deal with at a future date.

When a non-leaf node (a node with children) is clicked we basically switch the child data between children and _children and then call renderChart again to force the tree to be redrawn taking into account the hidden or on shown child nodes. Subsequent node clicks effectively toggle the display of child data for the node in question. The real genius of this approach (not mine by the way) is that the expanded/collapsed state of child nodes is preserved. This means that when descended nodes are expanded or collapsed their status will be restored when an ancestor node is subsequently expanded. This avoids having the user to expand every child node again whenever its parent node is collapsed. Simple when you know how!

Going back to the colour used to fill the node circle. Basically, what we are doing here is checking the _children property of the node and colouring it light steel blue if it has hidden children, otherwise fill the node circle white. So expanded nodes and leaf nodes will be coloured white and nodes that can be expanded but are currently collapsed will be filled with light steel blue. This provides a great visual cue to users so they can easily tell which nodes can be expanded.

Finally, you will see that I have assigned some style classes to elements. These classes are defined in the webpart.module.scss file as shown below:

.d3Test {

  .node {
    cursor: pointer;
  }

  .node circle {   
    stroke: steelblue;
    stroke-width: 1px;
  }

}

I set the node class to the group element and so the curser switches to a pointer whenever the user hovers over a node and so gives an indication that the node can be clicked. As explained above, nothing actually happens when a user clicks a leaf node so we could have used a function here to set a different class (one without a pointer cursor) if the node was a leaf node. However, I know that down the line I’m actually going to want something to happen when a user clicks a leaf node (just not entirely sure what yet), so we’ll leave things as they are, knowing that this is work in progress.

For circles within node groups elements I colour the outline of the circle in steel blue. The idea is that all node circles will be shown with a blue outline but filled white when expanded or the node is a leaf node and filled light steel blue when the node is a node with hidden children and so can be expanded when clicked

We can of course do much move with CSS but that’s enough to be getting on with.

Finally, finally, you may have noticed that I also pass in a parameter to allow us to reference the ChartConfig object that we instantiated earlier as a property of the web part class. In the code I am currently showing you we are not using this configuration data for anything and so this parameter could be omitted. However, always planning ahead, I know that at some stage I am going to want to use these configuration settings to control how the tree is rendered and so I’ll leave the parameter in the method signature so it is there when we need it.

The updateNode method

Back in the renderChart method we need to merge the set of nodes that already exist in the svg with those that need to be rendered as returned by the call to enterNode method and then update all the nodes that don’t need to be recreated by calling the updateNode method.

public static updateNode(node: any, config: ChartConfig){                 

    let nodeUpdate = node.transition()
                            .duration(Constants.DURATION_NODE_TRANSITIONS)
                            .attr("transform", function(d) {
                                        return `translate(${d.y}, ${d.x})`;
                                      });         

    nodeUpdate.select('circle')
                    .attr('r', 5)                      
                    .style("fill", function(d) {
                        return d._children ? "lightsteelblue" : "white";
                    });

}

This is a bit simpler than the enterNode method. Basically, for each node that already exists in the svg we are just moving it into its new position and then using the same colour coding technique to indicate whether the node is expanded or collapsed.

Notice also that instead of just making the nodes snap out into their new position we use a transition spread over a duration (set to 750 milliseconds i.e. ¾ of a second) that elegantly slides the node to its new position – cool!

The exitNode method

Ok, so enterNode has returned us all the nodes that need to be rendered and we have merged them with set the set of nodes that might already exist in the svg and updated the properties of those nodes but we also need to dispose of any nodes that might currently exist in the svg that we no longer need.

Back in the renderChart method we do this by calling the exitNode method as shown below:

D3Utilities.exitNode(node, sourceNode, config);

The exitNode method is shown below:

public static exitNode(node: any, sourceNode: any, config: ChartConfig){
      
    let nodeExit = node.exit()
                    .transition()
                    .duration(Constants.DURATION_NODE_TRANSITIONS)
                    .attr("transform", function(d) {
                      return `translate(${sourceNode.y}, ${sourceNode.x})`;
                      })
                    .remove();     

    // On exit reduce the node circles size to 0
    nodeExit.select('circle')
                .attr('r', 0);

    //On exit reduce the opacity of text labels
    nodeExit.select('text')
        .style('fill-opacity', 0);

}

First, we get D3 to returns us the nodes we no longer need and then remove them. But instead of just making them snap out of existence we use a transition again, that this time:

  • Slides the node’s position to that of the parent node i.e. the one that was just clicked and collapsed.
  • Reduces the radius of the node circle down to nothing.
  • Reduces the opacity of the node text so that it becomes progressively more transparent.

None of the above is strictly necessary, it just makes the UI look slinky and sexy.

Linking Nodes

So far, we have just taken care of the nodes but a tree has lines that link nodes to show the parent/child relationship between them and we have to deal with these links as well.

Let’s start back in the renderChart method and you can see we follow a pattern similar to how we dealt with the nodes.

//Get a handle on the links between nodes

let links = treeData.descendants().slice(1);       

let link = config.svg.selectAll('path').data(links, d => d.id);     

let linkEnter = D3Utilities.enterLink(link, sourceNode, config);
               
let linkUpdate = linkEnter.merge(link);

D3Utilities.updateLink(linkUpdate, config);

D3Utilities.exitLink(link, sourceNode, config);

First, we get a reference to all the links returned by the tree layout. Node links are represented by SVG path elements and so we then need to get a reference to all path elements (our links) that might already exist in the svg. Then, as with the nodes, we find out how many links we need to render, by calling the enterLink method then get the links that need updating by merging them with the links that may already and updating their properties by calling the updateLink method and then finally getting rid of any links we no longer need by calling the exitLink method.

Let’s look at each in turn.

The enterLink method

In the enterLink method we insert a path element and then call the linkPath method to render the link between a node and its parent.

public static enterLink(link : any, sourceNode: any, config: ChartConfig): any{

    // Enter any new links at the parent's previous position.
    let linkEnter = link.enter()                                          
                        .insert('path', "g")                       
                        .attr("class", `${styles.link}`)
                        .attr('d', function(d){
                            let o = {x: sourceNode.x0, y: sourceNode.y0};                               
                            return D3Utilities.linkPath(o, o, config);                             
                        });

    return linkEnter;

}

Note that the path element is assigned the link style class that is defined in the webpart.module.scss file as shown below:

.link {
    fill: none;
    stroke: #ccc;
    stroke-width: 2px;
 }

This will ensure that the link line is 2px wide and a medium grey colour is used.

The linkPath method

There are actually several ways in which we could draw a link that connects 2 nodes. I’ve come up with 3 main one but I’m sure that there are several more or variants of them at least. The 3 that I have identified are specified by the LinkType enumeration, which if you remember was define in the enums.ts file and used to define a property that was passed in to our ChartConfig parameter.

export enum LinkType{
  Arc,
  Line,
  Elbow
}

The linkPath method is really nothing more than a switch statement that calls a different link rending method based on the value passed in as the link property of the config parameter passed in to the method call.

public static linkPath(sourceNode, destinationNode, config: ChartConfig) : string {   

    switch (+config.link)
    {
        case LinkType.Arc: 
               return this.arcLink(sourceNode, destinationNode);
        case LinkType.Line: 
               return this.directLineLink(sourceNode, destinationNode);
        case LinkType.Elbow: 
               return this.elbowLink(sourceNode, destinationNode, config);
        default: "";
    }   

}

Later, we will add a control to the configuration pane of the web part so that the user can select the link connection type but initially we’ll settle for the a nicely smooth arc that connects nodes.

The 3 link rendering methods are shown below:

// Creates a curved (diagonal) path from parent to the child nodes
private static arcLink(sourceNode, destinationNode) : string {

    let path = `M ${sourceNode.y} ${sourceNode.x}
                C ${(sourceNode.y + destinationNode.y) / 2} ${sourceNode.x},
                ${(sourceNode.y + destinationNode.y) / 2} ${destinationNode.x},
                ${destinationNode.y} ${destinationNode.x}`;

    return path;

}

//Creates a straight line link between the nodes
private static directLineLink(sourceNode, destinationNode) : string {

    let path = `M ${sourceNode.y} ${sourceNode.x}
                L ${destinationNode.y} ${destinationNode.x}`;

    return path;

}

//Creates an elbow connector between the nodes
private static elbowLink(sourceNode, destinationNode, config: ChartConfig) : string {               

    let xDiff = (config.layerSpace / 2) * -1;       

    let path =  `M ${sourceNode.y} ${sourceNode.x} h ${xDiff} V ${destinationNode.x} H ${destinationNode.y}`;               

    return path;              

}

The SVG Path element is quite a complex but highly versatile construct and I’m not going to attempt to explain in detail how it can be used to used to draw lines in an SVG but basically you feed it a highly formatted text string of parameters and data points and then it works out how the line should be rendered. It is perhaps worth quickly calling out the following:

  • A sub-set of single text characters can be used in the format string and these are interpreted as parameters for numerical data elements that follow.
  • When a parameter character is capitalised, it means that the numeric values should be interpreted as absolution values within the parent SVG.
  • When a parameter character is in lower case, it means that the numeric values should be interpreted as relative values i.e. offsets for the previous value in the path.
  • Some parameters need 2 values to specify a point in x/y coordinates.
  • Some parameters need a single value, for example the v parameter is used to draw a vertical line relative to the previous location and so only needs a positive or negative height value.

I’m not going to walk you through each link rending function but if you are going to create your own variants bear in mind the links are drawn from a node to its parent node and not from a parent node to its child. That sort of makes sense when you think about it as each node only has to consider a single path i.e. from itself to its parent. If things were the other way around, then things get complicated as each node might have many links to its children or no links at all if it is a leaf node.

The updateLink method

Call the updateLink method to ensure that the link line gets updated to connect the child and parent nodes in their new positions

public static updateLink(link : any, config: ChartConfig){

    // Transition back to the parent element position
    link.transition()
        .duration(Constants.DURATION_NODE_TRANSITIONS)
        .attr('d', function(d){   
                 return D3Utilities.linkPath(d, d.parent, config);          
                 });

}

Again, we are using a transition to make it look cool.

The exitLink method

And we also need a way to dispose of any links that are no longer required.

public static exitLink(link : any, sourceNode: any, config: ChartConfig){

    link.exit().transition()
        .duration(Constants.DURATION_NODE_TRANSITIONS)
        .attr('d', function(d) {
                    var o = {x: sourceNode.x, y: sourceNode.y};
                    return D3Utilities.linkPath(o, o, config);          
                   })
        .remove();

}

And again, we use a transition to fade them out of existence.

The savePerviousLocations method

The last method to be called from renderChart is savePreviousLocation.

// Store the old positions for transition.
public static savePreviousLocation(nodes: any){     

    nodes.forEach(function(d: any){
                    d.x0 = d.x;
                    d.y0 = d.y;
                  });                          

}

This saves the old x, y coordinates of the node so that on nodeEnter they transitions to the correct position. If you look back at the enterNode method you will see that the transition uses the d.x0 and d.y0 properties.

Initialisation

We’re getting close now, hang in there.

Earlier, I glossed over the initialiseNodes method which gets called from web part’s render method, instead focusing on the renderChart method where most of the action happens, but we need to rectify that now. What the initialiseNodes method does is to go through all the level 2 nodes and their children and collapses them.

public static initialiseNodes(rootNode: any, x: number, y: number): void{

    rootNode.x0 = x;
    rootNode.y0 = y;

    rootNode.descendants().forEach((d, i) => {
                                    d.id = i;
                                    d._children = d.children;     
                                   });

    this.collapseRoot(rootNode);

}

The supporting methods are shown below:

private static collapseRoot(rootNode: any): void{

    rootNode.children.forEach(this.collapse);

}

private static collapse(d: any): void {

    function collapseNode(node): void {     

        if (node.children){

        node._children = node.children;

        node._children.forEach(child => {

            collapseNode(child);

            node.children = null;

        });    

        }

    }   

    if (d.children){

        d._children = d.children;

        d._children.forEach(child => {
                               collapseNode(child);
                               d.children = null;
                               });    

    }

}

The first thing this method does is previous position the root node in the svg by setting the x0 and y0 properties. This is because when the tree is created with a size the position of the root node is always set to (0, 0) in the coordinate system of the svg. Which, by the way, is the top left corner in SVG land and not bottom left as we might have expected from halcyon day of class room geometry. If you don’t do this then the transitions that bring the nodes and their links into view via the enterNode and enterLink methods when the tree is first loaded will start from the top left corner and that just doesn’t look right.

If you work through the rest of the initialiseNodes method, you will see that it goes through each child node of the root node then then recursively collapses each child node by relocating the child elements from children to _children so that D3 won’t pick them up for rendering.

If we didn’t initialise the nodes in this way then the tree would initially appear fully expanded, which might be what you want in some scenarios but not what I had in mind for here. We might also have chosen to collapse everything down to the root node alone but I reckon showing the root node, expanded to show layer one works well.

The grand reveal!

And so, we get to the grand reveal. Just gulp -server the project. Using the local workbench will work just fine for now because we are working with mock data, and add a web part instance to the page and if all goes well you should end up with something that looks like the following:

And we can expand and collapse and glide our way through the tree at will.

We can even click and drag to pan, and use the mouse wheel to zoom in and out.

In summary

I do appreciate that this post has been a bit of a beast.  In it we’ve covered off how to create an interactive tree visualisation in D3 using TypeScript rather than the raw JavaScript which would be the norm.  We’ve then been able to surface that tree in a SPFx web part.

The important thing is that we now have an understanding of how this can be achieved and so the same pattern can be reused whenever we might thing to create and interactive web part in this way.  Yes, there was no doubt some pain along the way but if you’ve managed to stay with me then climbing this rather steep learning curve will have been worth it.

What’s next?

Cool as this is, it doesn’t look very SharePointy. In the next post we’ll swap things out so that instead of circles used to represent nodes we’ll use icon graphic sucked up from SharePoint.

After that we’ll look at how to hook up web part properties so we can configure our chart area.

And, of course we’ve been using fake data up until now, so we’ll have to figure out how to get real data from a live SharePoint environment in a format that D3 can handle

Stay tuned.

6 comments

  1. I greatly enjoy the article but I have an issue with the code. No where do I see the hasChildren method in the D3Utilities class actually defined but in the enterNode method that method is being called twice

    Like

    1. @ Christopher, sorry I missed the hasChildren method out of the article. It’s actually very simple, it just returns true if the node has children or _children collection, irrespective of whether its being displayed or is hidden respectively.

      //Returns true if the node has child nodes
      public static hasChildren(node: any): boolean{
      return node.children || node._children;
      }

      Like

Leave a comment